Packages
fnord
0.9.5
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/services/background_indexer.ex
defmodule Services.BackgroundIndexer do
@moduledoc """
## Overview
The BackgroundIndexer is a silent, cancellable GenServer that indexes project files
one at a time. It generates per-file derivatives (summary, outline, embeddings)
and saves them to the project store.
This module is intentionally designed to be both:
- sequential (one file at a time) for predictability and resource control
- promptly cancellable so the parent command (e.g., `ask`) can stop it immediately
## Strategy (literate walkthrough)
- We do not pre-queue a large list of files. Instead, we:
1) Optionally accept a small explicit `files_queue` from the caller (mainly used by tests)
2) Otherwise fetch exactly one stale entry dynamically between tasks
- Each file is processed in a linked Task so the GenServer stays responsive
- We monitor the Task and, when it finishes, we schedule processing of the next file
- If there is no next file, we stop the server with `:normal`
- On shutdown, we kill any in-flight Task to guarantee prompt cancellation
## Lifecycle checkpoints
- `init/1`: set per-process HTTP pool, determine project & initial files_queue, prime state
- `handle_continue(:process_next)`: run the state machine to start the next Task or stop
- `handle_info({:DOWN, ...})`: clear task state and schedule the next step
- `terminate/2`: kill in-flight Task and clear HttpPool override
## Why one-at-a-time?
- Prevents overwhelming APIs (summaries, outlines, embeddings)
- Simplifies cancellation and error isolation
- Ensures the background indexer does not keep running long after `ask` completes
"""
use GenServer,
# Do not restart if it crashes; it should stop when done
restart: :temporary
@spec start_link(
opts :: [
{:project, Store.Project.t()}
| {:files, [Store.Project.Entry.t()]}
]
) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
@doc """
Stop the BackgroundIndexer GenServer safely.
This function is idempotent, swallows exits, and accepts non-pid values.
Always returns :ok.
"""
@spec stop(pid() | any()) :: :ok
def stop(pid) when is_pid(pid) do
# Attempt to stop the GenServer normally with a finite timeout.
# Catch any exit (normal, noproc, timeout) to ensure safe, idempotent behavior.
try do
GenServer.stop(pid, :normal, 5_000)
catch
:exit, _reason ->
:ok
end
end
# Accept any other values (nil, atom, etc.) and no-op.
def stop(_), do: :ok
@doc """
init/1 sets up the GenServer state, performing the following steps:
1. Configure HttpPool for AI indexer requests (efficient, reusable connections)
2. Determine `project` context from opts or Store.get_project/0
3. Build `files_queue` from opts (if provided) or default to []
4. Initialize state and immediately continue to :process_next
"""
@impl true
def init(opts) do
# Configure the HTTP connection pool for downstream embedding and summary
# API calls.
HttpPool.set(:ai_indexer)
Services.BgIndexingControl.ensure_init()
project =
case Keyword.get(opts, :project) do
%Store.Project{} = prj ->
prj
_ ->
case Store.get_project() do
{:ok, prj} -> prj
_ -> nil
end
end
files_queue =
case Keyword.get(opts, :files) do
list when is_list(list) -> list
_ -> []
end
state = %{
project: project,
impl: Indexer.impl(),
files_queue: files_queue,
task: nil,
mon_ref: nil
}
{:ok, state, {:continue, :process_next}}
end
# ----------------------------------------------------------------------------
# State machine for processing work
# ----------------------------------------------------------------------------
@doc """
handle_continue(:process_next) operates in these modes:
1) If a Task is already running, do nothing and wait for :DOWN
2) If files_queue has entries, pop one and start a Task to process it
3) If files_queue is empty but we have a project, fetch one stale entry
and start a Task for it; if none remain, stop normally
4) If there is no project and no files, stop normally
"""
@impl true
def handle_continue(:process_next, %{task: pid} = state)
when is_pid(pid) do
# A Task is already in-flight; we resume when it completes (:DOWN)
{:noreply, state}
end
@impl true
def handle_continue(:process_next, %{task: nil, files_queue: [entry | rest]} = state) do
if Services.BgIndexingControl.paused?(AI.Model.fast().model) or
Services.BgIndexingControl.paused?(AI.Embeddings.model_name()) do
{:stop, :normal, state}
else
# Start per-file work in a linked Task so the GenServer stays responsive
{:ok, task_pid} = Task.start_link(fn -> safe_process(entry, state.impl, state.project) end)
# Monitor the Task to receive a :DOWN message on completion
mon_ref = Process.monitor(task_pid)
new_state = %{state | task: task_pid, mon_ref: mon_ref, files_queue: rest}
# We only schedule the next step when the Task completes
{:noreply, new_state}
end
end
@impl true
def handle_continue(:process_next, %{task: nil, files_queue: [], project: project} = state)
when not is_nil(project) do
case next_stale_entry(project) do
nil ->
# No work remains; stop normally
{:stop, :normal, state}
entry ->
if Services.BgIndexingControl.paused?(AI.Model.fast().model) or
Services.BgIndexingControl.paused?(AI.Embeddings.model_name()) do
{:stop, :normal, state}
else
{:ok, task_pid} =
Task.start_link(fn -> safe_process(entry, state.impl, state.project) end)
mon_ref = Process.monitor(task_pid)
new_state = %{state | task: task_pid, mon_ref: mon_ref}
{:noreply, new_state}
end
end
end
@impl true
def handle_continue(:process_next, %{task: nil, files_queue: [], project: nil} = state) do
# No project and no queued files: nothing to do; stop normally
{:stop, :normal, state}
end
@doc """
When a monitored Task completes, we receive a :DOWN message.
We clear the task state and trigger the next file via {:continue, :process_next}.
This guarantees one-at-a-time processing and ensures prompt stop semantics.
"""
@impl true
def handle_info({:DOWN, ref, :process, pid, _reason}, %{mon_ref: ref, task: pid} = state) do
new_state = %{state | task: nil, mon_ref: nil}
{:noreply, new_state, {:continue, :process_next}}
end
@impl true
def handle_info(_msg, state) do
# Ignore any other messages
{:noreply, state}
end
@doc """
terminate/2 ensures prompt cancellation and cleanup:
- Kills any in-flight Task to stop work immediately
- Clears the HttpPool override for this process
"""
@impl true
def terminate(_reason, state) do
case state do
%{task: pid} when is_pid(pid) -> Process.exit(pid, :kill)
_ -> :ok
end
HttpPool.clear()
:ok
end
# ----------------------------------------------------------------------------
# Safely process a single entry: read, derive, and save.
#
# Steps:
# 1) Read the source file (falls back to empty string on read errors)
# 2) Generate summary and outline
# 3) Create an embedding input from [summary, outline, content]
# 4) Generate embeddings and persist to store
#
# Resilience:
# - Wrapped in try/rescue to isolate per-file failures
# - Logs end-of-step via UI without blocking main flow
# ----------------------------------------------------------------------------
defp safe_process(entry, impl, _project) do
try do
content =
case Store.Project.Entry.read_source_file(entry) do
{:ok, c} -> c
_ -> ""
end
path = entry.file
with {:ok, summary} = impl.get_summary(path, content),
{:ok, outline} = impl.get_outline(path, content),
embed_str = [summary, outline, content] |> Enum.join("\n\n"),
{:ok, embeddings} = impl.get_embeddings(embed_str) do
Store.Project.Entry.save(entry, summary, outline, embeddings)
detail =
if entry.project do
Store.Project.relative_path(entry.file, entry.project)
else
entry.file
end
UI.end_step_background("Indexed", "<file> " <> detail)
end
rescue
_ -> :ok
end
end
# ----------------------------------------------------------------------------
# Fetch only one stale entry at a time rather than pre-queuing all of them to:
# 1) Always pick up newly stale entries generated during ongoing processing
# 2) Limit memory/overhead by keeping the queue minimal
# 3) Avoid processing outdated entries if project state changes mid-run
# ----------------------------------------------------------------------------
@spec next_stale_entry(Store.Project.t()) :: Store.Project.Entry.t() | nil
defp next_stale_entry(project) do
project
|> Store.Project.index_status()
|> Map.get(:stale, [])
|> List.first()
end
end