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.
Files changed (101) hide show
  1. cursorfleet/__init__.py +9 -0
  2. cursorfleet/_templates/cursor/agents/architect.md +18 -0
  3. cursorfleet/_templates/cursor/agents/implementer.md +20 -0
  4. cursorfleet/_templates/cursor/agents/patcher.md +17 -0
  5. cursorfleet/_templates/cursor/agents/qa.md +21 -0
  6. cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
  7. cursorfleet/_templates/cursor/agents/scout.md +17 -0
  8. cursorfleet/_templates/cursor/agents/tester.md +20 -0
  9. cursorfleet/_templates/cursor/agents-md/root.md +7 -0
  10. cursorfleet/_templates/cursor/agents-md/work.md +7 -0
  11. cursorfleet/_templates/cursor/config/config.toml +47 -0
  12. cursorfleet/_templates/cursor/hooks/entry.json +4 -0
  13. cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
  14. cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
  15. cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
  16. cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
  17. cursorfleet/adapters/__init__.py +0 -0
  18. cursorfleet/adapters/cursor/__init__.py +0 -0
  19. cursorfleet/adapters/cursor/blocks.py +104 -0
  20. cursorfleet/adapters/cursor/diagnostics.py +547 -0
  21. cursorfleet/adapters/cursor/fsutil.py +156 -0
  22. cursorfleet/adapters/cursor/hook_main.py +428 -0
  23. cursorfleet/adapters/cursor/hook_normalize.py +527 -0
  24. cursorfleet/adapters/cursor/hook_policy.py +76 -0
  25. cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
  26. cursorfleet/adapters/cursor/hooksjson.py +305 -0
  27. cursorfleet/adapters/cursor/installer.py +626 -0
  28. cursorfleet/adapters/cursor/kit.py +210 -0
  29. cursorfleet/adapters/cursor/kit_probe.py +54 -0
  30. cursorfleet/adapters/cursor/lock.py +145 -0
  31. cursorfleet/adapters/cursor/templates.py +60 -0
  32. cursorfleet/adapters/cursor/validation.py +365 -0
  33. cursorfleet/adapters/cursor/workspace.py +80 -0
  34. cursorfleet/cli/__init__.py +0 -0
  35. cursorfleet/cli/commands/__init__.py +1 -0
  36. cursorfleet/cli/commands/_common.py +82 -0
  37. cursorfleet/cli/commands/_resolution.py +56 -0
  38. cursorfleet/cli/commands/doctor.py +81 -0
  39. cursorfleet/cli/commands/events.py +197 -0
  40. cursorfleet/cli/commands/index.py +55 -0
  41. cursorfleet/cli/commands/init.py +71 -0
  42. cursorfleet/cli/commands/migrate.py +43 -0
  43. cursorfleet/cli/commands/replay.py +76 -0
  44. cursorfleet/cli/commands/run.py +97 -0
  45. cursorfleet/cli/commands/status.py +210 -0
  46. cursorfleet/cli/commands/tui.py +47 -0
  47. cursorfleet/cli/commands/uninstall.py +73 -0
  48. cursorfleet/cli/commands/validate.py +69 -0
  49. cursorfleet/cli/main.py +81 -0
  50. cursorfleet/config/__init__.py +44 -0
  51. cursorfleet/config/io.py +27 -0
  52. cursorfleet/config/models.py +154 -0
  53. cursorfleet/config/roster.py +245 -0
  54. cursorfleet/events/__init__.py +39 -0
  55. cursorfleet/events/forbidden.py +45 -0
  56. cursorfleet/events/ids.py +109 -0
  57. cursorfleet/events/kinds.py +132 -0
  58. cursorfleet/events/models.py +213 -0
  59. cursorfleet/events/pathcheck.py +45 -0
  60. cursorfleet/git/__init__.py +0 -0
  61. cursorfleet/git/collector.py +226 -0
  62. cursorfleet/git/runner.py +135 -0
  63. cursorfleet/git/worktrees.py +71 -0
  64. cursorfleet/policy/__init__.py +0 -0
  65. cursorfleet/py.typed +0 -0
  66. cursorfleet/safeexe.py +70 -0
  67. cursorfleet/schema_gen.py +73 -0
  68. cursorfleet/state/__init__.py +0 -0
  69. cursorfleet/state/artifact_scan.py +309 -0
  70. cursorfleet/state/context.py +38 -0
  71. cursorfleet/state/event_store.py +227 -0
  72. cursorfleet/state/indexer.py +443 -0
  73. cursorfleet/state/lock.py +74 -0
  74. cursorfleet/state/models.py +268 -0
  75. cursorfleet/state/reducer.py +588 -0
  76. cursorfleet/state/retention.py +218 -0
  77. cursorfleet/state/run_builder.py +136 -0
  78. cursorfleet/state/run_model.py +43 -0
  79. cursorfleet/state/run_store.py +95 -0
  80. cursorfleet/state/runtime.py +645 -0
  81. cursorfleet/state/spool.py +186 -0
  82. cursorfleet/state/spool_read.py +242 -0
  83. cursorfleet/state/status_doc.py +293 -0
  84. cursorfleet/tui/__init__.py +0 -0
  85. cursorfleet/tui/app.py +581 -0
  86. cursorfleet/tui/data.py +545 -0
  87. cursorfleet/tui/filters.py +82 -0
  88. cursorfleet/tui/gates.py +255 -0
  89. cursorfleet/tui/help.py +101 -0
  90. cursorfleet/tui/layout.py +26 -0
  91. cursorfleet/tui/theme.py +176 -0
  92. cursorfleet/tui/views.py +1077 -0
  93. cursorfleet/tui/watch.py +33 -0
  94. cursorfleet/workflow/__init__.py +0 -0
  95. cursorfleet/workflow/artifacts.py +148 -0
  96. cursorfleet/workflow/frontmatter.py +232 -0
  97. cursorfleet-0.1.0.dist-info/METADATA +161 -0
  98. cursorfleet-0.1.0.dist-info/RECORD +101 -0
  99. cursorfleet-0.1.0.dist-info/WHEEL +4 -0
  100. cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
  101. 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()