Module: ClaudeAgentSDK::SessionStores

Defined in:
lib/claude_agent_sdk/session_store.rb

Overview

Internal SessionStore support functions (path mapping, option validation).

Constant Summary collapse

STORE_CALLBACK_SCHEDULING_MODES =
%i[thread inline].freeze

Class Method Summary collapse

Class Method Details

.file_path_to_session_key(file_path, projects_dir) ⇒ Object

Derive a SessionKey from an absolute transcript file path.

Main:     <projects_dir>/<project_key>/<session_id>.jsonl
Subagent: <projects_dir>/<project_key>/<session_id>/subagents/agent-<id>.jsonl

Returns nil if file_path is not under projects_dir or has an unrecognized shape.



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/claude_agent_sdk/session_store.rb', line 310

def file_path_to_session_key(file_path, projects_dir)
  # A frame with a missing/non-String filePath would make Pathname.new raise
  # TypeError (not the ArgumentError handled below), which propagates out of
  # do_flush and drops the entire coalesced drain batch. Treat it as
  # "not under projects_dir" so only the bad frame is skipped.
  return nil unless file_path.is_a?(String) && !file_path.empty?

  begin
    rel = Pathname.new(file_path).relative_path_from(Pathname.new(projects_dir)).to_s
  rescue ArgumentError
    # Different drives on Windows — treat as "not under projects_dir".
    return nil
  end

  parts = rel.split('/')
  # Reject paths that escape projects_dir: a leading ".." *segment* (exact
  # match, so a legitimate dir like "..foo" still maps), the "." self-ref,
  # or an absolute path. Comparing parts[0] rather than rel.start_with?("..")
  # avoids the "..foo" false positive that would silently drop valid frames.
  return nil if parts.empty? || parts[0] == '..' || rel == '.' || Pathname.new(rel).absolute?
  return nil if parts.length < 2

  project_key = parts[0]
  second = parts[1]

  # Main transcript: <project_key>/<session_id>.jsonl
  return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') } if parts.length == 2 && second.end_with?('.jsonl')

  # Subagent transcript: <project_key>/<session_id>/subagents/.../agent-<id>.jsonl
  if parts.length >= 4
    subpath_parts = parts[2..]
    subpath_parts[-1] = subpath_parts[-1].delete_suffix('.jsonl')
    # Subpaths are always /-joined so keys are portable across platforms.
    return { 'project_key' => project_key, 'session_id' => second, 'subpath' => subpath_parts.join('/') }
  end

  nil
end

.projects_dir(env_override = nil) ⇒ Object

Path to the rel-from base where session transcripts live, honoring a CLAUDE_CONFIG_DIR override passed to the subprocess via options.env. Mirrors Sessions#config_dir but consults an explicit env override first.

Presence is detected by KEY, not value: the transport treats an explicit nil value as "unset the var for the child", so the CLI then writes under the default ~/.claude — not under the parent's CLAUDE_CONFIG_DIR. Empty strings get the same treatment (the Node CLI treats "" as unset).



391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/claude_agent_sdk/session_store.rb', line 391

def projects_dir(env_override = nil)
  if env_override.respond_to?(:key?) &&
     (env_override.key?('CLAUDE_CONFIG_DIR') || env_override.key?(:CLAUDE_CONFIG_DIR))
    override = env_override['CLAUDE_CONFIG_DIR'] || env_override[:CLAUDE_CONFIG_DIR]
    override = nil if override.respond_to?(:empty?) && override.empty?
    # NFC like Python's _get_projects_dir(env_override) — a decomposed
    # Unicode override would otherwise mismatch the NFC paths used for
    # the mirror's projects-dir prefix comparison and drop every frame.
    override = override.unicode_normalize(:nfc) if override
    return File.join(override || File.expand_path('~/.claude'), 'projects')
  end

  File.join(Sessions.config_dir, 'projects')
end

.store_callback_scheduling(store) ⇒ Object

Where an adapter's timeout-bounded #append/#load calls run: :thread (default — hard-bounded thread hop) unless the adapter declares fiber-nativeness via an optional callback_scheduling method returning :inline (String form coerced, matching ClaudeAgentOptions). Probed respond_to?-style like the rest of the subsystem. Called once at construction time (batcher initialize, resume-materialization entry) so an invalid declaration fails fast there, not mid-session.



289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/claude_agent_sdk/session_store.rb', line 289

def store_callback_scheduling(store)
  return :thread unless store.respond_to?(:callback_scheduling)

  value = store.callback_scheduling
  mode = value.respond_to?(:to_sym) ? value.to_sym : value
  unless STORE_CALLBACK_SCHEDULING_MODES.include?(mode)
    raise ArgumentError,
          'session_store#callback_scheduling must return one of ' \
          "#{STORE_CALLBACK_SCHEDULING_MODES.map(&:inspect).join(', ')} (got #{value.inspect})"
  end

  mode
end

.validate_session_store_options(options) ⇒ Object

Raise ArgumentError for invalid session_store option combinations. Called before subprocess spawn so misconfiguration fails fast.

Raises:

  • (ArgumentError)


351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/claude_agent_sdk/session_store.rb', line 351

def validate_session_store_options(options)
  store = options.session_store
  return if store.nil?

  # #append/#load are required, but a subclass inheriting the base stubs
  # would only fail at first use — with NotImplementedError, a ScriptError
  # that rescue StandardError layers don't catch. Fail fast here instead.
  %i[append load].each do |method|
    raise ArgumentError, "session_store must implement ##{method}" unless SessionStore.implements?(store, method)
  end

  flush = options.session_store_flush.to_s
  unless SESSION_STORE_FLUSH_MODES.include?(flush)
    raise ArgumentError,
          "invalid session_store_flush: #{options.session_store_flush.inspect} " \
          "(expected one of #{SESSION_STORE_FLUSH_MODES.join(', ')})"
  end

  # When resume is explicitly set, list_sessions is provably never called
  # (resume wins over continue), so a minimal store is fine.
  if options.continue_conversation && options.resume.nil? && !SessionStore.implements?(store, :list_sessions)
    raise ArgumentError,
          'continue_conversation with session_store requires the store to implement #list_sessions'
  end

  return unless options.enable_file_checkpointing

  raise ArgumentError,
        'session_store cannot be combined with enable_file_checkpointing ' \
        '(checkpoints are local-disk only and would diverge from the mirrored transcript)'
end