cursorfleet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Single-writer SQLite WAL projection and incremental spool indexer.
|
|
2
|
+
|
|
3
|
+
- Hooks never touch SQLite. This module is the only writer, guarded by ``IndexerLock``.
|
|
4
|
+
- The database is a cache: it is rebuildable from the spool. A corrupt or
|
|
5
|
+
wrong-version database is moved to ``quarantine/`` and rebuilt.
|
|
6
|
+
- Tailing: per spool file we keep a byte offset keyed by ``(session dir, fingerprint of
|
|
7
|
+
the first line)``, so a size-rotation rename does not cause a re-read. Offsets only
|
|
8
|
+
advance past complete lines; a torn tail is retried.
|
|
9
|
+
- Equivalence with replay: new events are applied incrementally only if they sort after
|
|
10
|
+
the session watermark; otherwise that session is rebuilt from the spool. Either way the
|
|
11
|
+
result equals ``reduce_events`` over the whole spool (property-tested).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sqlite3
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from contextlib import contextmanager, suppress
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
from pydantic import ValidationError
|
|
25
|
+
|
|
26
|
+
from cursorfleet.events.ids import fs_name_for
|
|
27
|
+
from cursorfleet.events.models import Event
|
|
28
|
+
from cursorfleet.state.event_store import (
|
|
29
|
+
EVENTS_DDL,
|
|
30
|
+
delete_session_events,
|
|
31
|
+
insert_events,
|
|
32
|
+
)
|
|
33
|
+
from cursorfleet.state.lock import IndexerBusy, IndexerLock
|
|
34
|
+
from cursorfleet.state.models import SessionAcc
|
|
35
|
+
from cursorfleet.state.reducer import apply_event, dedupe_and_sort, reduce_events, sort_key
|
|
36
|
+
from cursorfleet.state.runtime import FILE_MODE, RuntimePaths, mkdir_private, untrusted_reason
|
|
37
|
+
from cursorfleet.state.spool_read import (
|
|
38
|
+
Corruption,
|
|
39
|
+
accept_placement,
|
|
40
|
+
list_spool_files,
|
|
41
|
+
peek_fingerprint,
|
|
42
|
+
read_all,
|
|
43
|
+
read_file,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
DB_SCHEMA_VERSION = 1
|
|
47
|
+
|
|
48
|
+
_DDL = (
|
|
49
|
+
"""
|
|
50
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
52
|
+
session_dir TEXT NOT NULL,
|
|
53
|
+
fingerprint TEXT NOT NULL,
|
|
54
|
+
path TEXT NOT NULL,
|
|
55
|
+
offset INTEGER NOT NULL,
|
|
56
|
+
corruption TEXT NOT NULL,
|
|
57
|
+
PRIMARY KEY (session_dir, fingerprint)
|
|
58
|
+
) WITHOUT ROWID;
|
|
59
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
60
|
+
session_id TEXT PRIMARY KEY,
|
|
61
|
+
state TEXT NOT NULL,
|
|
62
|
+
watermark_ts TEXT NOT NULL,
|
|
63
|
+
watermark_id TEXT NOT NULL
|
|
64
|
+
) WITHOUT ROWID;
|
|
65
|
+
CREATE TABLE IF NOT EXISTS seen (
|
|
66
|
+
session_id TEXT NOT NULL,
|
|
67
|
+
event_id TEXT NOT NULL,
|
|
68
|
+
PRIMARY KEY (session_id, event_id)
|
|
69
|
+
) WITHOUT ROWID;
|
|
70
|
+
"""
|
|
71
|
+
+ EVENTS_DDL
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class SyncStats:
|
|
77
|
+
files_scanned: int = 0
|
|
78
|
+
events_read: int = 0
|
|
79
|
+
events_new: int = 0
|
|
80
|
+
sessions_updated: int = 0
|
|
81
|
+
sessions_rebuilt: int = 0
|
|
82
|
+
sessions_pruned: int = 0
|
|
83
|
+
db_recovered: bool = False
|
|
84
|
+
corruption: Corruption = field(default_factory=Corruption)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _json_to_corruption(text: str) -> Corruption:
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(text)
|
|
90
|
+
return Corruption.from_dict({k: v for k, v in data.items() if isinstance(v, int)})
|
|
91
|
+
except (ValueError, AttributeError, TypeError):
|
|
92
|
+
return Corruption()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _corruption_to_json(counts: Corruption) -> str:
|
|
96
|
+
return json.dumps(counts.to_dict(), sort_keys=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _release_sqlite(conn: sqlite3.Connection) -> None:
|
|
100
|
+
"""Checkpoint WAL and close so Windows can rename the projection files.
|
|
101
|
+
|
|
102
|
+
A live WAL/SHM handle keeps ``state.sqlite`` locked after ``close()`` on
|
|
103
|
+
Windows; ``os.replace`` then fails, recovery reopens the planted database,
|
|
104
|
+
and the next sync raises again.
|
|
105
|
+
"""
|
|
106
|
+
with suppress(sqlite3.Error):
|
|
107
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
108
|
+
with suppress(sqlite3.Error):
|
|
109
|
+
conn.execute("PRAGMA journal_mode=DELETE")
|
|
110
|
+
with suppress(sqlite3.Error):
|
|
111
|
+
conn.close()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class UntrustedRuntime(IndexerBusy):
|
|
115
|
+
"""The runtime directory is not private to this user (symlink, other owner, group-writable)."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Projection:
|
|
119
|
+
"""Thin wrapper around the SQLite file (open, validate, recover)."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, paths: RuntimePaths) -> None:
|
|
122
|
+
self._paths = paths
|
|
123
|
+
self.recovered = False
|
|
124
|
+
|
|
125
|
+
def quarantine(self) -> None:
|
|
126
|
+
mkdir_private(self._paths.quarantine)
|
|
127
|
+
stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
|
|
128
|
+
for suffix in ("", "-wal", "-shm"):
|
|
129
|
+
source = self._paths.db + suffix
|
|
130
|
+
if os.path.exists(source):
|
|
131
|
+
target = os.path.join(
|
|
132
|
+
self._paths.quarantine, f"state.sqlite{suffix}.{stamp}.{os.getpid()}"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
os.replace(source, target)
|
|
136
|
+
except OSError:
|
|
137
|
+
with suppress(OSError):
|
|
138
|
+
os.remove(source)
|
|
139
|
+
|
|
140
|
+
def _create_private(self) -> None:
|
|
141
|
+
mkdir_private(self._paths.index_dir)
|
|
142
|
+
if not os.path.exists(self._paths.db):
|
|
143
|
+
fd = os.open(
|
|
144
|
+
self._paths.db,
|
|
145
|
+
os.O_WRONLY | os.O_CREAT | getattr(os, "O_BINARY", 0),
|
|
146
|
+
FILE_MODE,
|
|
147
|
+
)
|
|
148
|
+
os.close(fd)
|
|
149
|
+
if os.name == "posix":
|
|
150
|
+
os.chmod(self._paths.db, FILE_MODE)
|
|
151
|
+
|
|
152
|
+
def _open_checked(self) -> sqlite3.Connection:
|
|
153
|
+
conn = sqlite3.connect(self._paths.db, timeout=10.0, isolation_level=None)
|
|
154
|
+
try:
|
|
155
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
156
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
157
|
+
version = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
158
|
+
if version not in (0, DB_SCHEMA_VERSION):
|
|
159
|
+
msg = f"unsupported projection schema version {version}"
|
|
160
|
+
raise sqlite3.DatabaseError(msg)
|
|
161
|
+
conn.executescript(_DDL)
|
|
162
|
+
conn.execute(f"PRAGMA user_version={DB_SCHEMA_VERSION}")
|
|
163
|
+
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()
|
|
164
|
+
return conn
|
|
165
|
+
except BaseException:
|
|
166
|
+
_release_sqlite(conn)
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
def connect(self) -> sqlite3.Connection:
|
|
170
|
+
self._create_private()
|
|
171
|
+
try:
|
|
172
|
+
return self._open_checked()
|
|
173
|
+
except sqlite3.DatabaseError:
|
|
174
|
+
self.quarantine()
|
|
175
|
+
self.recovered = True
|
|
176
|
+
self._create_private()
|
|
177
|
+
return self._open_checked()
|
|
178
|
+
|
|
179
|
+
def reset(self) -> None:
|
|
180
|
+
"""Delete the projection (used by ``rebuild``)."""
|
|
181
|
+
for suffix in ("", "-wal", "-shm"):
|
|
182
|
+
try:
|
|
183
|
+
os.remove(self._paths.db + suffix)
|
|
184
|
+
except OSError:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Indexer:
|
|
189
|
+
"""Tails the spool into the projection. Construct, then call :meth:`sync`."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, paths: RuntimePaths) -> None:
|
|
192
|
+
self._paths = paths
|
|
193
|
+
self._projection = Projection(paths)
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------ public API
|
|
196
|
+
|
|
197
|
+
def sync(self, *, rebuild: bool = False) -> SyncStats:
|
|
198
|
+
"""Index new spool lines. Raises :class:`IndexerBusy` if another indexer runs.
|
|
199
|
+
|
|
200
|
+
Raises :class:`UntrustedRuntime` (an ``IndexerBusy``) when the runtime directory is
|
|
201
|
+
not private to this user; nothing is read, created or written in that case.
|
|
202
|
+
"""
|
|
203
|
+
reason = untrusted_reason(self._paths.root)
|
|
204
|
+
if reason is not None:
|
|
205
|
+
msg = f"the runtime directory {reason}; refusing to index (see `cursorfleet doctor`)"
|
|
206
|
+
raise UntrustedRuntime(msg)
|
|
207
|
+
with IndexerLock(self._paths.indexer_lock):
|
|
208
|
+
if rebuild:
|
|
209
|
+
self._projection.reset()
|
|
210
|
+
conn = self._projection.connect()
|
|
211
|
+
try:
|
|
212
|
+
stats = SyncStats(db_recovered=self._projection.recovered or rebuild)
|
|
213
|
+
try:
|
|
214
|
+
self._sync(conn, stats)
|
|
215
|
+
except (ValueError, ValidationError):
|
|
216
|
+
# A stored row that no longer validates (corrupt or planted database):
|
|
217
|
+
# the projection is only a cache, so quarantine it and rebuild once.
|
|
218
|
+
# Release WAL first: Windows cannot rename a projection that still
|
|
219
|
+
# has an open WAL/SHM handle.
|
|
220
|
+
_release_sqlite(conn)
|
|
221
|
+
self._projection.quarantine()
|
|
222
|
+
conn = self._projection.connect()
|
|
223
|
+
stats = SyncStats(db_recovered=True)
|
|
224
|
+
self._sync(conn, stats)
|
|
225
|
+
return stats
|
|
226
|
+
finally:
|
|
227
|
+
conn.close()
|
|
228
|
+
|
|
229
|
+
def try_sync(self) -> SyncStats | None:
|
|
230
|
+
"""Like :meth:`sync` but returns ``None`` when another indexer is active."""
|
|
231
|
+
try:
|
|
232
|
+
return self.sync()
|
|
233
|
+
except IndexerBusy:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
def load_sessions(self) -> dict[str, SessionAcc]:
|
|
237
|
+
"""Read the projection (no locking; WAL readers never block the writer)."""
|
|
238
|
+
if not os.path.exists(self._paths.db) or untrusted_reason(self._paths.root) is not None:
|
|
239
|
+
return {}
|
|
240
|
+
try:
|
|
241
|
+
conn = self._projection.connect()
|
|
242
|
+
except (sqlite3.DatabaseError, OSError):
|
|
243
|
+
return {}
|
|
244
|
+
try:
|
|
245
|
+
return {
|
|
246
|
+
row[0]: SessionAcc.model_validate_json(row[1])
|
|
247
|
+
for row in conn.execute("SELECT session_id, state FROM sessions ORDER BY 1")
|
|
248
|
+
}
|
|
249
|
+
except (sqlite3.DatabaseError, ValueError, ValidationError):
|
|
250
|
+
return {}
|
|
251
|
+
finally:
|
|
252
|
+
conn.close()
|
|
253
|
+
|
|
254
|
+
def corruption(self) -> Corruption:
|
|
255
|
+
"""Cumulative corruption counters across indexed files."""
|
|
256
|
+
total = Corruption()
|
|
257
|
+
if not os.path.exists(self._paths.db) or untrusted_reason(self._paths.root) is not None:
|
|
258
|
+
return total
|
|
259
|
+
try:
|
|
260
|
+
conn = self._projection.connect()
|
|
261
|
+
except (sqlite3.DatabaseError, OSError):
|
|
262
|
+
return total
|
|
263
|
+
try:
|
|
264
|
+
for (text,) in conn.execute("SELECT corruption FROM files"):
|
|
265
|
+
total.add(_json_to_corruption(text))
|
|
266
|
+
except sqlite3.DatabaseError:
|
|
267
|
+
pass
|
|
268
|
+
finally:
|
|
269
|
+
conn.close()
|
|
270
|
+
return total
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------ internals
|
|
273
|
+
|
|
274
|
+
@contextmanager
|
|
275
|
+
def _tx(self, conn: sqlite3.Connection) -> Iterator[None]:
|
|
276
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
277
|
+
try:
|
|
278
|
+
yield
|
|
279
|
+
except BaseException:
|
|
280
|
+
conn.execute("ROLLBACK")
|
|
281
|
+
raise
|
|
282
|
+
conn.execute("COMMIT")
|
|
283
|
+
|
|
284
|
+
def _ensure_event_store(self, conn: sqlite3.Connection) -> None:
|
|
285
|
+
"""Projections written before the ``events`` table existed are re-indexed once."""
|
|
286
|
+
if conn.execute("SELECT 1 FROM meta WHERE key='event_store'").fetchone() is not None:
|
|
287
|
+
return
|
|
288
|
+
with self._tx(conn):
|
|
289
|
+
conn.execute("DELETE FROM files")
|
|
290
|
+
conn.execute("DELETE FROM sessions")
|
|
291
|
+
conn.execute("DELETE FROM seen")
|
|
292
|
+
conn.execute("DELETE FROM events")
|
|
293
|
+
conn.execute("INSERT OR REPLACE INTO meta(key, value) VALUES ('event_store', '1')")
|
|
294
|
+
|
|
295
|
+
def _sync(self, conn: sqlite3.Connection, stats: SyncStats) -> None:
|
|
296
|
+
self._ensure_event_store(conn)
|
|
297
|
+
batches: dict[str, list[Event]] = {}
|
|
298
|
+
file_updates: list[tuple[str, str, str, int, str]] = []
|
|
299
|
+
live_keys: set[tuple[str, str]] = set()
|
|
300
|
+
files = list_spool_files(self._paths)
|
|
301
|
+
for path in files:
|
|
302
|
+
fingerprint = peek_fingerprint(path)
|
|
303
|
+
if fingerprint is None:
|
|
304
|
+
continue
|
|
305
|
+
session_dir = os.path.basename(os.path.dirname(path))
|
|
306
|
+
live_keys.add((session_dir, fingerprint))
|
|
307
|
+
row = conn.execute(
|
|
308
|
+
"SELECT offset, corruption FROM files WHERE session_dir=? AND fingerprint=?",
|
|
309
|
+
(session_dir, fingerprint),
|
|
310
|
+
).fetchone()
|
|
311
|
+
offset = int(row[0]) if row else 0
|
|
312
|
+
previous = _json_to_corruption(row[1]) if row else Corruption()
|
|
313
|
+
result = read_file(path, offset)
|
|
314
|
+
accepted = [e for e in result.events if accept_placement(path, e, result.corruption)]
|
|
315
|
+
stats.files_scanned += 1
|
|
316
|
+
stats.events_read += len(accepted)
|
|
317
|
+
if result.new_offset < offset or (offset > result.size):
|
|
318
|
+
previous = Corruption() # file was truncated/replaced: counters restart
|
|
319
|
+
previous.add(result.corruption)
|
|
320
|
+
stats.corruption.add(result.corruption)
|
|
321
|
+
file_updates.append(
|
|
322
|
+
(session_dir, fingerprint, path, result.new_offset, _corruption_to_json(previous))
|
|
323
|
+
)
|
|
324
|
+
for event in accepted:
|
|
325
|
+
batches.setdefault(event.session_id, []).append(event)
|
|
326
|
+
with self._tx(conn):
|
|
327
|
+
for session_id in sorted(batches):
|
|
328
|
+
self._index_session(conn, session_id, batches[session_id], stats)
|
|
329
|
+
conn.executemany(
|
|
330
|
+
"INSERT OR REPLACE INTO files(session_dir, fingerprint, path, offset, corruption)"
|
|
331
|
+
" VALUES (?,?,?,?,?)",
|
|
332
|
+
file_updates,
|
|
333
|
+
)
|
|
334
|
+
self._prune(conn, live_keys, stats)
|
|
335
|
+
conn.execute(
|
|
336
|
+
"INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)",
|
|
337
|
+
(str(DB_SCHEMA_VERSION),),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _index_session(
|
|
341
|
+
self, conn: sqlite3.Connection, session_id: str, events: list[Event], stats: SyncStats
|
|
342
|
+
) -> None:
|
|
343
|
+
fresh = [
|
|
344
|
+
e
|
|
345
|
+
for e in dedupe_and_sort(events)
|
|
346
|
+
if conn.execute(
|
|
347
|
+
"SELECT 1 FROM seen WHERE session_id=? AND event_id=?", (session_id, e.event_id)
|
|
348
|
+
).fetchone()
|
|
349
|
+
is None
|
|
350
|
+
]
|
|
351
|
+
if not fresh:
|
|
352
|
+
return
|
|
353
|
+
stats.events_new += len(fresh)
|
|
354
|
+
row = conn.execute(
|
|
355
|
+
"SELECT state FROM sessions WHERE session_id=?", (session_id,)
|
|
356
|
+
).fetchone()
|
|
357
|
+
try:
|
|
358
|
+
acc = SessionAcc.model_validate_json(row[0]) if row else None
|
|
359
|
+
except (ValueError, ValidationError) as exc:
|
|
360
|
+
# pydantic_core.ValidationError is not always a ValueError (Windows/3.14).
|
|
361
|
+
raise ValueError("stored session row does not validate") from exc
|
|
362
|
+
out_of_order = acc is not None and sort_key(fresh[0]) <= (
|
|
363
|
+
acc.watermark_ts,
|
|
364
|
+
acc.watermark_id,
|
|
365
|
+
)
|
|
366
|
+
if out_of_order:
|
|
367
|
+
self._rebuild_session(conn, session_id, stats)
|
|
368
|
+
return
|
|
369
|
+
for event in fresh:
|
|
370
|
+
acc = apply_event(acc, event)
|
|
371
|
+
assert acc is not None # noqa: S101 - fresh is non-empty
|
|
372
|
+
conn.executemany(
|
|
373
|
+
"INSERT OR IGNORE INTO seen(session_id, event_id) VALUES (?,?)",
|
|
374
|
+
[(session_id, e.event_id) for e in fresh],
|
|
375
|
+
)
|
|
376
|
+
insert_events(conn, fresh)
|
|
377
|
+
self._store(conn, acc)
|
|
378
|
+
stats.sessions_updated += 1
|
|
379
|
+
|
|
380
|
+
def _rebuild_session(self, conn: sqlite3.Connection, session_id: str, stats: SyncStats) -> None:
|
|
381
|
+
all_events, _corruption, _files = read_all(self._paths, session_id)
|
|
382
|
+
# Include events from other session dirs that claim this id (dir name != id).
|
|
383
|
+
rebuilt = reduce_events(all_events).get(session_id)
|
|
384
|
+
conn.execute("DELETE FROM seen WHERE session_id=?", (session_id,))
|
|
385
|
+
delete_session_events(conn, session_id)
|
|
386
|
+
if rebuilt is None:
|
|
387
|
+
conn.execute("DELETE FROM sessions WHERE session_id=?", (session_id,))
|
|
388
|
+
return
|
|
389
|
+
unique = dedupe_and_sort(all_events)
|
|
390
|
+
conn.executemany(
|
|
391
|
+
"INSERT OR IGNORE INTO seen(session_id, event_id) VALUES (?,?)",
|
|
392
|
+
[(session_id, e.event_id) for e in unique],
|
|
393
|
+
)
|
|
394
|
+
insert_events(conn, [e for e in unique if e.session_id == session_id])
|
|
395
|
+
self._store(conn, rebuilt)
|
|
396
|
+
stats.sessions_rebuilt += 1
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _store(conn: sqlite3.Connection, acc: SessionAcc) -> None:
|
|
400
|
+
conn.execute(
|
|
401
|
+
"INSERT OR REPLACE INTO sessions(session_id, state, watermark_ts, watermark_id)"
|
|
402
|
+
" VALUES (?,?,?,?)",
|
|
403
|
+
(acc.session_id, acc.model_dump_json(), acc.watermark_ts.isoformat(), acc.watermark_id),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _prune(
|
|
407
|
+
self, conn: sqlite3.Connection, live_keys: set[tuple[str, str]], stats: SyncStats
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Drop projection rows whose spool is gone (retention prunes spool and projection)."""
|
|
410
|
+
for session_dir, fingerprint in conn.execute(
|
|
411
|
+
"SELECT session_dir, fingerprint FROM files"
|
|
412
|
+
).fetchall():
|
|
413
|
+
if (session_dir, fingerprint) not in live_keys:
|
|
414
|
+
conn.execute(
|
|
415
|
+
"DELETE FROM files WHERE session_dir=? AND fingerprint=?",
|
|
416
|
+
(session_dir, fingerprint),
|
|
417
|
+
)
|
|
418
|
+
for (session_id,) in conn.execute("SELECT session_id FROM sessions").fetchall():
|
|
419
|
+
directory = os.path.join(self._paths.spool, fs_name_for(session_id))
|
|
420
|
+
if not os.path.isdir(directory):
|
|
421
|
+
conn.execute("DELETE FROM sessions WHERE session_id=?", (session_id,))
|
|
422
|
+
conn.execute("DELETE FROM seen WHERE session_id=?", (session_id,))
|
|
423
|
+
delete_session_events(conn, session_id)
|
|
424
|
+
stats.sessions_pruned += 1
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def replay_session_state(
|
|
428
|
+
paths: RuntimePaths, session_id: str | None = None
|
|
429
|
+
) -> tuple[dict[str, SessionAcc], Corruption, int]:
|
|
430
|
+
"""Pure replay straight from the spool (no SQLite). Used by ``replay`` and as fallback."""
|
|
431
|
+
events, corruption, files = read_all(paths, session_id)
|
|
432
|
+
return reduce_events(events), corruption, files
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
__all__ = [
|
|
436
|
+
"DB_SCHEMA_VERSION",
|
|
437
|
+
"Indexer",
|
|
438
|
+
"IndexerBusy",
|
|
439
|
+
"Projection",
|
|
440
|
+
"SyncStats",
|
|
441
|
+
"UntrustedRuntime",
|
|
442
|
+
"replay_session_state",
|
|
443
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Cross-platform advisory lock for the single SQLite writer (``locks/indexer.lock``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
|
|
9
|
+
from cursorfleet.state.runtime import FILE_MODE, mkdir_private
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IndexerBusy(RuntimeError):
|
|
13
|
+
"""Another indexer holds the lock."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IndexerLock:
|
|
17
|
+
"""Non-blocking exclusive lock held for the lifetime of the object / ``with`` block."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: str) -> None:
|
|
20
|
+
self._path = path
|
|
21
|
+
self._fd: int | None = None
|
|
22
|
+
|
|
23
|
+
def acquire(self) -> bool:
|
|
24
|
+
mkdir_private(os.path.dirname(self._path))
|
|
25
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
26
|
+
fd = os.open(self._path, flags, FILE_MODE)
|
|
27
|
+
try:
|
|
28
|
+
if sys.platform == "win32":
|
|
29
|
+
import msvcrt
|
|
30
|
+
|
|
31
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
32
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
33
|
+
else:
|
|
34
|
+
import fcntl
|
|
35
|
+
|
|
36
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
37
|
+
except OSError:
|
|
38
|
+
os.close(fd)
|
|
39
|
+
return False
|
|
40
|
+
self._fd = fd
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
def release(self) -> None:
|
|
44
|
+
fd, self._fd = self._fd, None
|
|
45
|
+
if fd is None:
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
if sys.platform == "win32":
|
|
49
|
+
import msvcrt
|
|
50
|
+
|
|
51
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
52
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
53
|
+
else:
|
|
54
|
+
import fcntl
|
|
55
|
+
|
|
56
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
finally:
|
|
60
|
+
os.close(fd)
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> IndexerLock:
|
|
63
|
+
if not self.acquire():
|
|
64
|
+
msg = "another cursorfleet indexer holds the lock"
|
|
65
|
+
raise IndexerBusy(msg)
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(
|
|
69
|
+
self,
|
|
70
|
+
exc_type: type[BaseException] | None,
|
|
71
|
+
exc: BaseException | None,
|
|
72
|
+
tb: TracebackType | None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.release()
|