Module: Agent::Sessions::Adapters::Enumeration

Included in:
Base
Defined in:
lib/agent/sessions/adapters/enumeration.rb

Overview

Layer 2: turning a resolved store into sessions. Split out of Base once it held three concerns at 460 lines, and before Layer 3 readers add a fourth.

Mixed into Base rather than included per adapter, so every adapter keeps inheriting all of this and overriding the hooks it needs — the extraction is a move, not a change in how an adapter is written.

What this half needs from Layer 1 is deliberately small, and worth keeping small: primary_layer (the store to enumerate and the format to stamp on each session) and the class-level DSL readers agent_name and fidelity_value. Nothing here resolves a path, reads an env override, or touches @env. A method that needs to do any of those belongs in Base.

Constant Summary collapse

MAX_LINE_BYTES =

Caps how many bytes one iteration of a JSONL scan may pull into memory. "One line" is not a bounded quantity on disk: a record carrying a pasted file or a base64 image is routinely tens of MB, and a truncated file may hold no newline at all. An over-long line arrives as chunks of this size, which fail to parse and are skipped, so the scan gives up rather than reading a 2.6 GB file into a single String.

1_000_000

Instance Method Summary collapse

Instance Method Details

#bytes_for(_path, stat) ⇒ Object

Bytes this session occupies on disk. The transcript alone for a store that keeps one file per session; an adapter whose agent writes sidecar files beside the transcript overrides this and adds them. Like the two time hooks it takes the stat the enumerator already holds, so the common case still costs nothing beyond the syscall already made.

An override runs EAGERLY for every session, so it carries build_session's constraint: it must not raise on an unreadable path, or one bad sidecar takes down the whole listing rather than its own row.



164
# File 'lib/agent/sessions/adapters/enumeration.rb', line 164

def bytes_for(_path, stat) = stat.size

#encode_project(_dir) ⇒ Object

nil means this adapter has no directory-name fallback rule. Used only by sessions_for_project, and only for a session whose own recorded cwd could not be read. When overridden: dir arrives pre-expanded here from sessions_for_project (File.expand_path), which is the precondition an override may rely on — a direct caller must pass an absolute, expanded path itself, or the encoding is nonsense ("app", "~/app", and a trailing slash all encode differently from the canonical form real project directories were named from).



128
# File 'lib/agent/sessions/adapters/enumeration.rb', line 128

def encode_project(_dir) = nil

#project_dir_name(path) ⇒ Object

The directory whose name the encoding must match, when sessions_for_project falls back to it. Overridable: not every store puts the encoded project directly above the session file — cursor_ide nests projects//agent-transcripts/*, where the immediate parent is agent-transcripts and matching it would find nothing, silently.



135
# File 'lib/agent/sessions/adapters/enumeration.rb', line 135

def project_dir_name(path) = File.basename(File.dirname(path))

#project_path_for(_path) ⇒ Object

nil means the project is unknown for this session. Adapters override with a bounded read of their own metadata; Base cannot guess.



139
# File 'lib/agent/sessions/adapters/enumeration.rb', line 139

def project_path_for(_path) = nil

#project_pathsObject

Distinct recorded project paths, sorted. This is the read-everything direction (design doc section 7): the encodings cannot be reversed, so the recorded cwd inside each file is the only reliable source. Sessions whose project cannot be determined are excluded, not returned as nil.

Sorted rather than left in glob order because a stable order is what makes projects output diffable and du --by project deterministic — and adapters answering from a database would otherwise impose their own.



110
111
112
# File 'lib/agent/sessions/adapters/enumeration.rb', line 110

def project_paths
  sessions.filter_map(&:project_path).uniq.force.sort
end

#session_id_from(path) ⇒ Object

--- Layer 2 hooks, overridable per adapter ---



116
117
118
# File 'lib/agent/sessions/adapters/enumeration.rb', line 116

def session_id_from(path)
  File.basename(path, ".*")
end

#sessionsObject

Lazily enumerates the primary store. Each consumed session costs one stat plus filename parsing — never a content read. project_path is the exception and pays for itself on first access.

A store the gem has no layout for is refused rather than reported empty: Location#enumerable? exists precisely so "nothing here to enumerate" and "enumerated, found none" stay distinguishable, and silently returning no sessions is this gem's worst failure mode.



35
36
37
38
39
40
41
42
# File 'lib/agent/sessions/adapters/enumeration.rb', line 35

def sessions
  unless primary_layer.enumerable?
    raise Error, "#{self.class.agent_name} store #{primary_layer.kind} at " \
                 "#{primary_layer.path} has no known layout to enumerate"
  end

  enumerate(primary_layer.files)
end

#sessions_for_project(dir) ⇒ Object

Match by RECORDED cwd, exact, per session (design doc section 7, revised 2026-08-05 — the third design for this method, kept honest here because the next reader deserves to know why it is not "cheap"). The first two designs were built and disproved against a real store, not in theory:

1. Directory-name matching (the original design) assumed a
 session's parent directory equals encode(its own recorded cwd).
 A project rename breaks that: the agent keeps writing under the
 OLD encoded directory, so two directories can hold live sessions
 for the SAME current cwd, and name-only matching silently
 dropped the stale one — false negatives, the failure mode this
 gem treats as worst (decision 11).

2. One-read-per-directory sampling (the first fix for #1) assumed
 sessions within a directory share a cwd, to keep the match
 sublinear. Reading a real renamed project's stale directory
 disproved that: two of its three sessions had been resumed after
 the rename and recorded the NEW cwd; the third was never resumed
 and still recorded the OLD one. Sampling one session and
 applying its verdict to the whole directory is wrong in BOTH
 directions on the same store — it invented a false positive
 here, and a different glob order would just as easily have
 reproduced #1's false negative for that same directory.
 Approximate cwd resolution doesn't make the error smaller; it
 just moves where it lands.

Measured cost of reading every session instead of sampling: 0.17 ms per session (68 real Claude sessions, full sweep, 0.012s total) — 0.7s extrapolated to a 4,000-session store. That is what the sampling complexity was buying, and it is not a trade worth making: the enumerator is already lazy, so a caller taking first(n) never pays for sessions it never asked about, and even the worst case (every session checked, no match) stays under a second on a store two orders of magnitude larger than anything observed.

A session whose own cwd cannot be read (the scan gave up, the file is unreadable, the adapter declares no reader) falls back to comparing ITS OWN directory's name against the encoding, when the adapter declares one — this is the only thing encode_project still buys: it keeps a session with an unreadable header from becoming invisible, without resolving an unknown project for every other session that happens to share its directory.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/agent/sessions/adapters/enumeration.rb', line 87

def sessions_for_project(dir)
  dir = File.expand_path(dir)
  encoded = encode_project(dir)
  sessions.select do |session|
    # expand_path does not resolve symlinks, so a cwd recorded as
    # /private/tmp/x will not match a caller's /tmp/x on macOS.
    # Deliberate: realpath would cost a stat per comparison to fix a
    # rare mismatch.
    cwd = session.project_path
    next cwd == dir unless cwd.nil?

    encoded && project_dir_name(session.path) == encoded
  end
end

#started_at_for(_path, stat) ⇒ Object

Both time hooks take the stat the enumerator already holds, so a session still costs one syscall. path is passed for adapters that answer from a sibling metadata file instead.

nil beats a wrong guess when the filesystem cannot answer at all — and it says so through ENOSYS/EPERM from statx as often as NotImplementedError.



147
148
149
150
151
# File 'lib/agent/sessions/adapters/enumeration.rb', line 147

def started_at_for(_path, stat)
  stat.birthtime
rescue NotImplementedError, SystemCallError
  nil
end

#updated_at_for(_path, stat) ⇒ Object



153
# File 'lib/agent/sessions/adapters/enumeration.rb', line 153

def updated_at_for(_path, stat) = stat.mtime