-
Notifications
You must be signed in to change notification settings - Fork 7
feat: add idempotent transcript window backfill #599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
86cafc2
09eb2ea
f32ecb8
bdc15e8
ae0f85b
3148fcb
8cbd5af
5330bdf
349655d
bf88748
37b7905
4dfcc6b
fdc020c
cfc6afb
f459924
5fe9e99
c11942f
39abf1e
ed0a041
e43e1fb
90ac44b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| """Idempotent time-window filtering for transcript watcher backfills.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import errno | ||
| import fcntl | ||
| from collections.abc import Callable | ||
| from contextlib import contextmanager | ||
| from datetime import UTC, datetime | ||
| from pathlib import Path | ||
| from typing import Iterator | ||
|
|
||
| from .watcher_bridge import FlushWatermarks | ||
|
|
||
|
|
||
| def _parse_utc(value: str) -> datetime: | ||
| parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) | ||
| if parsed.tzinfo is None: | ||
| parsed = parsed.replace(tzinfo=UTC) | ||
| return parsed.astimezone(UTC) | ||
|
|
||
|
|
||
| def parse_backfill_window(since: str | None, until: str | None) -> tuple[datetime, datetime]: | ||
| """Parse a required half-open UTC interval and reject empty ranges.""" | ||
| if not since or not until: | ||
| raise ValueError("--since and --until must be provided together") | ||
| try: | ||
| parsed_since = _parse_utc(since) | ||
| parsed_until = _parse_utc(until) | ||
| except (ValueError, OverflowError) as exc: | ||
| raise ValueError("--since and --until must be ISO 8601 timestamps") from exc | ||
| if parsed_since >= parsed_until: | ||
| raise ValueError("--since must be earlier than --until") | ||
| return parsed_since, parsed_until | ||
|
|
||
|
|
||
| def window_registry_suffix(since: datetime, until: datetime) -> str: | ||
| """Return a stable filesystem-safe name for a backfill interval.""" | ||
|
|
||
| def format_timestamp(value: datetime) -> str: | ||
| base = f"{value:%Y%m%dT%H%M%S}" | ||
| fraction = f"{value.microsecond:06d}" if value.microsecond else "" | ||
| return f"{base}{fraction}Z" | ||
|
|
||
| return f"{format_timestamp(since)}-{format_timestamp(until)}" | ||
|
|
||
|
|
||
| class BackfillAlreadyRunning(RuntimeError): | ||
| """Raised when another process owns the same registry-scoped backfill.""" | ||
|
|
||
|
|
||
| @contextmanager | ||
| def backfill_run_lock(registry_path: str | Path) -> Iterator[None]: | ||
| """Serialize scan, enqueue, and offset persistence for one registry.""" | ||
| registry = Path(registry_path).expanduser() | ||
| registry.parent.mkdir(parents=True, exist_ok=True) | ||
| lock_path = registry.with_name(f"{registry.name}.backfill.lock") | ||
| with lock_path.open("a+b") as lock_file: | ||
| try: | ||
| fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) | ||
| except OSError as exc: | ||
| if exc.errno not in {errno.EACCES, errno.EAGAIN}: | ||
| raise | ||
| raise BackfillAlreadyRunning(f"another backfill is using registry {registry}") from exc | ||
| try: | ||
| yield | ||
| finally: | ||
| fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) | ||
|
|
||
|
|
||
| def _adjacent_pair_indices(parts: tuple[str, ...], first: str, second: str) -> list[int]: | ||
| return [index for index in range(len(parts) - 1) if parts[index : index + 2] == (first, second)] | ||
|
|
||
|
|
||
| def is_legacy_excluded_path(path: str | Path) -> bool: | ||
| """Identify roots blocked by the blanket denylist retired in July 2026.""" | ||
| parts = Path(path).expanduser().parts | ||
| if _adjacent_pair_indices(parts, ".codex", "sessions"): | ||
| return True | ||
| if _adjacent_pair_indices(parts, ".gemini", "sessions"): | ||
| return True | ||
| for cursor_index, part in enumerate(parts): | ||
| if part == ".cursor" and "agent-transcripts" in parts[cursor_index + 1 :]: | ||
| return True | ||
| for claude_index in _adjacent_pair_indices(parts, ".claude", "projects"): | ||
| if "subagents" in parts[claude_index + 3 :]: | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| class WindowedFlush: | ||
| """Filter normalized watcher entries while confirming every scanned offset.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| downstream: Callable[[list[dict]], dict[str, int] | None], | ||
| *, | ||
| since: datetime, | ||
| until: datetime, | ||
| source_predicate: Callable[[str | Path], bool] | None = None, | ||
| ) -> None: | ||
| self.downstream = downstream | ||
| self.since = since | ||
| self.until = until | ||
| self.source_predicate = source_predicate | ||
| self.scanned_entries = 0 | ||
| self.matched_entries = 0 | ||
| self.inserted_chunks = 0 | ||
|
|
||
| def _matches(self, entry: dict) -> bool: | ||
| source_file = entry.get("_source_file") | ||
| if self.source_predicate and (not isinstance(source_file, str) or not self.source_predicate(source_file)): | ||
| return False | ||
| if entry.get("_timestamp_synthesized") is True: | ||
| return False | ||
| timestamp = entry.get("timestamp") | ||
| if not isinstance(timestamp, str): | ||
| return False | ||
| try: | ||
| parsed = _parse_utc(timestamp) | ||
| except (ValueError, OverflowError): | ||
| return False | ||
|
macroscopeapp[bot] marked this conversation as resolved.
EtanHey marked this conversation as resolved.
|
||
| return self.since <= parsed < self.until | ||
|
|
||
| def __call__(self, entries: list[dict]) -> FlushWatermarks | None: | ||
| match_flags = [self._matches(entry) for entry in entries] | ||
| matched = [entry for entry, matches in zip(entries, match_flags, strict=True) if matches] | ||
| downstream_result = self.downstream(matched) if matched else FlushWatermarks() | ||
| self.scanned_entries += len(entries) | ||
| self.matched_entries += len(matched) | ||
| if downstream_result is None: | ||
| return None | ||
| watermarks = dict(downstream_result or {}) | ||
| by_source: dict[str, list[tuple[int, bool]]] = {} | ||
| for entry, matches in zip(entries, match_flags, strict=True): | ||
| source_file = entry.get("_source_file") | ||
| offset = entry.get("_line_end_offset") | ||
| if isinstance(source_file, str) and isinstance(offset, int): | ||
| by_source.setdefault(source_file, []).append((offset, matches)) | ||
| for source_file, source_entries in by_source.items(): | ||
| confirmed = int(watermarks.get(source_file, 0)) | ||
| for offset, matches in sorted(source_entries): | ||
| if offset <= confirmed: | ||
| continue | ||
| if matches: | ||
| break | ||
| confirmed = offset | ||
| if confirmed > 0: | ||
| watermarks[source_file] = confirmed | ||
|
|
||
| inserted = int(getattr(downstream_result, "inserted", len(matched))) | ||
| downstream_skipped = int(getattr(downstream_result, "skipped", 0)) | ||
| self.inserted_chunks += inserted | ||
| return FlushWatermarks( | ||
| watermarks, | ||
| inserted=inserted, | ||
| skipped=downstream_skipped + len(entries) - len(matched), | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3491,51 +3491,141 @@ def watch_backfill( | |
| "--registry", | ||
| help="Offset registry path. Defaults to the live BrainLayer offsets file.", | ||
| ), | ||
| since: Optional[str] = typer.Option(None, "--since", help="Inclusive ISO 8601 entry timestamp."), | ||
| until: Optional[str] = typer.Option(None, "--until", help="Exclusive ISO 8601 entry timestamp."), | ||
| legacy_excluded_only: bool = typer.Option( | ||
| False, | ||
| "--legacy-excluded-only", | ||
| help="Replay only transcript roots blocked by the retired blanket denylist.", | ||
| ), | ||
| dry_run: bool = typer.Option(False, "--dry-run", help="Report files that would be replayed without writing."), | ||
| max_cycles: int = typer.Option(100, "--max-cycles", min=1, help="Maximum poll cycles to run."), | ||
| ) -> None: | ||
| """One-shot replay for watched JSONL roots using the durable queue writer path.""" | ||
| from ..backfill import ( | ||
| BackfillAlreadyRunning, | ||
| WindowedFlush, | ||
| backfill_run_lock, | ||
| is_legacy_excluded_path, | ||
| parse_backfill_window, | ||
| window_registry_suffix, | ||
| ) | ||
| from ..ingest_denylist import is_legacy_backfill_denylisted | ||
| from ..paths import get_db_path | ||
| from ..watcher import JSONLWatcher, WatchRoot, default_watch_roots | ||
| from ..watcher_bridge import create_flush_callback | ||
|
|
||
| db_path = get_db_path() | ||
| registry_path = registry.expanduser() if registry else db_path.parent / "offsets.json" | ||
| window = None | ||
| if since is not None or until is not None: | ||
| try: | ||
| window = parse_backfill_window(since, until) | ||
| except ValueError as exc: | ||
| raise typer.BadParameter(str(exc), param_hint="--since/--until") from exc | ||
| if legacy_excluded_only and window is None: | ||
| raise typer.BadParameter( | ||
| "--legacy-excluded-only requires --since and --until", | ||
| param_hint="--legacy-excluded-only", | ||
| ) | ||
| if registry: | ||
| registry_path = registry.expanduser() | ||
| elif window: | ||
| scope_suffix = "-legacy-excluded-only" if legacy_excluded_only else "" | ||
| registry_path = db_path.parent / f"backfill-offsets-{window_registry_suffix(*window)}{scope_suffix}.json" | ||
|
Comment on lines
+3530
to
+3534
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a time-windowed run, accepting an explicit Useful? React with 👍 / 👎. |
||
| else: | ||
| registry_path = db_path.parent / "offsets.json" | ||
| watch_roots = [WatchRoot("custom", item) for item in source] if source else default_watch_roots(home=home) | ||
| discovery_predicate = is_legacy_excluded_path if legacy_excluded_only else None | ||
|
|
||
| if dry_run: | ||
| watcher = JSONLWatcher( | ||
| watch_roots=watch_roots, | ||
| registry_path=registry_path, | ||
| on_flush=lambda items: None, | ||
| db_path=db_path, | ||
| denylist_predicate=is_legacy_backfill_denylisted if legacy_excluded_only else None, | ||
| discovery_predicate=discovery_predicate, | ||
| preserve_raw_progress=window is not None, | ||
| prune_missing_offsets=not legacy_excluded_only, | ||
| ) | ||
| files = watcher._discover_jsonl_files() | ||
| provider_counts: dict[str, int] = {} | ||
| for path in files: | ||
| provider = watcher.provider_for_file(path) | ||
| provider_counts[provider] = provider_counts.get(provider, 0) + 1 | ||
|
EtanHey marked this conversation as resolved.
|
||
| provider_summary = " ".join(f"{provider}={count}" for provider, count in sorted(provider_counts.items())) | ||
| rprint(f"candidate_files={len(files)} {provider_summary} processed_entries=0 registry={registry_path}") | ||
| window_summary = f" window=[{window[0].isoformat()},{window[1].isoformat()})" if window else "" | ||
| scope_summary = " scope=legacy-excluded-only" if legacy_excluded_only else "" | ||
| rprint( | ||
| f"candidate_files={len(files)} {provider_summary} processed_entries=0{window_summary}{scope_summary} " | ||
| f"registry={registry_path}" | ||
| ) | ||
| return | ||
|
|
||
| watcher = JSONLWatcher( | ||
| watch_roots=watch_roots, | ||
| registry_path=registry_path, | ||
| on_flush=create_flush_callback(db_path, arbitrated=True), | ||
| db_path=db_path, | ||
| ) | ||
| processed = 0 | ||
| cycles = 0 | ||
| while cycles < max_cycles: | ||
| cycles += 1 | ||
| count = watcher.poll_once() | ||
| if count == 0: | ||
| break | ||
| processed += count | ||
| watcher.indexer.flush() | ||
| watcher.registry.flush() | ||
| rprint(f"processed_entries={processed} cycles={cycles} registry={registry_path}") | ||
| try: | ||
| with backfill_run_lock(registry_path): | ||
| downstream_flush = create_flush_callback(db_path, arbitrated=True) | ||
| windowed_flush = ( | ||
| WindowedFlush( | ||
| downstream_flush, | ||
| since=window[0], | ||
| until=window[1], | ||
| source_predicate=discovery_predicate, | ||
| ) | ||
| if window | ||
| else None | ||
| ) | ||
| watcher = JSONLWatcher( | ||
| watch_roots=watch_roots, | ||
| registry_path=registry_path, | ||
| on_flush=windowed_flush or downstream_flush, | ||
| db_path=db_path, | ||
| denylist_predicate=is_legacy_backfill_denylisted if legacy_excluded_only else None, | ||
| discovery_predicate=discovery_predicate, | ||
| preserve_raw_progress=window is not None, | ||
| max_read_bytes_per_file=1_048_576, | ||
| prune_missing_offsets=not legacy_excluded_only, | ||
| ) | ||
| processed = 0 | ||
| cycles = 0 | ||
| while cycles < max_cycles: | ||
| cycles += 1 | ||
| positions_before = { | ||
| path: (tailer.offset, tailer.offset + len(tailer._buffer)) | ||
| for path, tailer in watcher._tailers.items() | ||
| } | ||
| count = watcher.poll_once() | ||
| processed += count | ||
| made_progress = any( | ||
| tailer.offset > positions_before.get(path, (0, 0))[0] | ||
| or tailer.offset + len(tailer._buffer) > positions_before.get(path, (0, 0))[1] | ||
| for path, tailer in watcher._tailers.items() | ||
| ) | ||
| if not made_progress: | ||
| break | ||
| watcher.indexer.flush() | ||
| incomplete = ( | ||
| watcher.has_pending_input() | ||
| or watcher.indexer.retained_failed_input_count() > 0 | ||
| or watcher.indexer.total_quarantined_inputs > 0 | ||
| ) | ||
| registry_flushed = watcher.registry.flush() | ||
| incomplete = incomplete or not registry_flushed | ||
| except BackfillAlreadyRunning as exc: | ||
| rprint(f"[red]{exc}[/]") | ||
| raise typer.Exit(code=2) from exc | ||
|
|
||
| incomplete_summary = f" incomplete={'true' if incomplete else 'false'}" | ||
| if windowed_flush: | ||
| rprint( | ||
| f"processed_entries={processed} scanned_entries={windowed_flush.scanned_entries} " | ||
| f"matched_entries={windowed_flush.matched_entries} queued_chunks={windowed_flush.inserted_chunks} " | ||
| f"cycles={cycles}{incomplete_summary} registry={registry_path}" | ||
| ) | ||
| else: | ||
| rprint(f"processed_entries={processed} cycles={cycles}{incomplete_summary} registry={registry_path}") | ||
| if incomplete: | ||
| raise typer.Exit(code=1) | ||
|
|
||
|
|
||
| @app.command("index-fast", hidden=True) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,12 @@ | |
| BRAINLAYER_INGEST_DENYLIST_ENV = "BRAINLAYER_INGEST_DENYLIST" | ||
|
|
||
| DEFAULT_INGEST_DENYLIST = ("~/.claude/projects/**/wf_*/**",) | ||
| RETIRED_BLANKET_INGEST_DENYLIST = ( | ||
| "~/.claude/projects/*/**/subagents/**", | ||
| "~/.codex/sessions/**", | ||
| "~/.cursor/**/agent-transcripts/**", | ||
| "~/.gemini/sessions/**", | ||
| ) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
|
|
@@ -64,6 +70,15 @@ def _match_parts(path_parts: tuple[str, ...], pattern_parts: tuple[str, ...]) -> | |
| return fnmatch.fnmatchcase(path_parts[0], pattern_parts[0]) and _match_parts(path_parts[1:], pattern_parts[1:]) | ||
|
|
||
|
|
||
| def _matches_patterns(candidate: Path, patterns: tuple[str, ...]) -> bool: | ||
| homes = _inferred_homes(candidate) | ||
| return any( | ||
| _match_parts(candidate.parts, expanded_pattern.parts) | ||
| for pattern in patterns | ||
| for expanded_pattern in _expand_globs(pattern, homes) | ||
| ) | ||
|
|
||
|
|
||
| def _is_claude_subagent(path: Path) -> bool: | ||
| parts = path.parts | ||
| return ".claude" in parts and "projects" in parts and "subagents" in parts | ||
|
|
@@ -156,12 +171,21 @@ def _claude_subagent_attribution(path: Path) -> str | None: | |
| def is_denylisted(path: str | Path, *, unknown_subagent_is_denylisted: bool = True) -> bool: | ||
| """Return True when a source path is under an ingest-denylisted transcript root.""" | ||
| candidate = Path(os.path.abspath(os.path.expanduser(str(path)))) | ||
| homes = _inferred_homes(candidate) | ||
| for pattern in _configured_patterns(): | ||
| for expanded_pattern in _expand_globs(pattern, homes): | ||
| if _match_parts(candidate.parts, expanded_pattern.parts): | ||
| return True | ||
| if _matches_patterns(candidate, _configured_patterns()): | ||
| return True | ||
| if BRAINLAYER_INGEST_DENYLIST_ENV not in os.environ and _is_claude_subagent(candidate): | ||
| attribution = _claude_subagent_attribution(candidate) | ||
| return (attribution is None and unknown_subagent_is_denylisted) or attribution == "brain-worker" | ||
| return False | ||
|
|
||
|
|
||
| def is_legacy_backfill_denylisted(path: str | Path) -> bool: | ||
| """Apply current safety exclusions while retiring only the old blanket roots.""" | ||
| candidate = Path(os.path.abspath(os.path.expanduser(str(path)))) | ||
| configured = tuple(pattern for pattern in _configured_patterns() if pattern not in RETIRED_BLANKET_INGEST_DENYLIST) | ||
| active_patterns = tuple(dict.fromkeys((*DEFAULT_INGEST_DENYLIST, *configured))) | ||
| if _matches_patterns(candidate, active_patterns): | ||
| return True | ||
| if _is_claude_subagent(candidate): | ||
| return _claude_subagent_attribution(candidate) == "brain-worker" | ||
|
Comment on lines
+189
to
+190
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a legacy Claude subagent whose Useful? React with 👍 / 👎. |
||
| return False | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: EtanHey/brainlayer
Length of output: 4310
🏁 Script executed:
Repository: EtanHey/brainlayer
Length of output: 13527
Use a canonical, cross-platform run lock in
src/brainlayer/backfill.py:53-68.backfill_run_lockstill importsfcntldirectly, so this path is broken on Windows. It also keys the lock off the raw registry path, so symlink/alias paths can take separate locks and run concurrently. Resolve the registry path before deriving the lock and reuse the shared POSIX/Windows lock helper.🤖 Prompt for AI Agents
Source: Coding guidelines