Sagents.FileSystemServer (Sagents v0.4.2)

Copy Markdown

GenServer managing virtual filesystem with debounce-based auto-persistence.

Manages file storage in GenServer state and handles per-file debounce timers.

This server is designed to outlive AgentServer crashes when supervised with :rest_for_one strategy, providing crash resilience.

Supervision

FileSystemServer should be the first child in an AgentSupervisor with :rest_for_one strategy. This ensures that if AgentServer crashes, FileSystemServer survives and preserves all filesystem state.

Graceful Shutdown

FileSystemServer traps exits to ensure graceful shutdown. When the process terminates (via supervisor shutdown or any other reason), it automatically flushes all pending debounced writes to persistence before terminating.

Configuration

  • :scope_key - Scope identifier (required) - Can be any unique term
    • Tuple format: {:user, 123}, {:agent, "uuid"}, {:project, id}
    • UUID string: "550e8400-e29b-41d4-a716-446655440000"
    • Database ID: "12345"
  • :configs - List of FileSystemConfig structs (optional, default: [])
  • :pubsub - PubSub configuration as {module(), atom()} tuple or nil (optional, default: nil) Example: {Phoenix.PubSub, :my_app_pubsub} When configured, broadcasts {:files_updated, file_list} after write/delete operations.

Examples

# Memory-only filesystem with tuple scope
{:ok, pid} = start_link(scope_key: {:user, 123})

# Memory-only filesystem with UUID
{:ok, pid} = start_link(scope_key: "550e8400-e29b-41d4-a716-446655440000")

# Memory-only filesystem with database ID
{:ok, pid} = start_link(scope_key: 789)

# With disk persistence (tuple scope)
{:ok, config} = FileSystemConfig.new(%{
  base_directory: "Memories",
  persistence_module: Sagents.FileSystem.Persistence.Disk,
  debounce_ms: 5000,
  storage_opts: [path: "/data/users/123"]
})
{:ok, pid} = start_link(
  scope_key: {:user, 123},
  configs: [config]
)

Summary

Functions

Child spec for starting under a supervisor.

Create a directory entry in the filesystem.

Delete file or directory from filesystem.

Check if a file exists in the filesystem.

Flush all pending debounce timers and persist immediately.

Get the via tuple name for a scope key.

Get all registered persistence configurations.

Get the scope key for a FileSystemServer PID.

List all file entries in the filesystem (content NOT loaded).

List all file paths in the filesystem.

Moves a file or directory (and its children) from one path to another.

Read a file entry from filesystem with lazy loading.

Register file entries in the filesystem.

Register a new persistence configuration.

Reset the filesystem to pristine persisted state.

Start FileSystemServer for a scope.

Get filesystem statistics.

Subscribe to file change events for a filesystem scope.

Unsubscribe from file change events for a filesystem scope.

Update only the metadata.custom map for a file entry.

Update entry-level fields on a file entry.

Get the FileSystemServer PID by scope key.

Write content to a file path.

Functions

child_spec(init_arg)

Child spec for starting under a supervisor.

create_directory(scope_key, path, opts \\ [])

@spec create_directory(term(), String.t(), keyword()) ::
  {:ok, Sagents.FileSystem.FileEntry.t()} | {:error, term()}

Create a directory entry in the filesystem.

Directories have no content and are persisted immediately.

Options

  • :title - Human-readable name
  • :custom - Custom metadata map

Examples

iex> {:ok, entry} = create_directory({:user, 123}, "/Characters", title: "Characters")
iex> entry.entry_type
:directory

delete_file(scope_key, path)

@spec delete_file(term(), String.t()) :: :ok | {:error, term()}

Delete file or directory from filesystem.

If file was persisted, it's also removed from storage immediately (no debounce).

file_exists?(scope_key, path)

@spec file_exists?(term(), String.t()) :: boolean()

Check if a file exists in the filesystem.

Examples

iex> file_exists?({:user, 123}, "/notes.txt")
true

iex> file_exists?({:user, 123}, "/nonexistent.txt")
false

flush_all(scope_key)

@spec flush_all(term()) :: :ok

Flush all pending debounce timers and persist immediately.

Useful for graceful shutdown or checkpoints.

get_name(scope_key)

Get the via tuple name for a scope key.

The scope_key can be any term that uniquely identifies the scope. Common patterns include tuples like {:user, 123} or strings like "agent-abc".

get_persistence_configs(scope_key)

@spec get_persistence_configs(term()) :: %{
  required(String.t()) => Sagents.FileSystem.FileSystemConfig.t()
}

Get all registered persistence configurations.

Returns a map of base_directory => FileSystemConfig.

Examples

iex> FileSystemServer.get_persistence_configs({:user, 123})
%{"user_files" => %FileSystemConfig{}, "S3" => %FileSystemConfig{}}

get_scope(pid)

@spec get_scope(pid()) :: {:ok, term()} | {:error, term()}

Get the scope key for a FileSystemServer PID.

Returns the scope_key that was used to start the server.

list_entries(scope_key)

@spec list_entries(nil | term()) :: [Sagents.FileSystem.FileEntry.t()]

List all file entries in the filesystem (content NOT loaded).

Returns entries with metadata suitable for building sidebar trees, directory listings, or LLM ls tool responses.

Examples

iex> entries = list_entries({:user, 123})
iex> Enum.map(entries, & &1.path)
["/Characters/Hero", "/Notes/Outline"]

list_files(scope_key)

@spec list_files(nil | term()) :: [String.t()]

List all file paths in the filesystem.

Returns paths for both memory and persisted files, regardless of load status.

Examples

iex> list_files({:user, 123})
["/file1.txt", "/Memories/file2.txt"]

move_file(scope_key, old_path, new_path)

@spec move_file(term(), String.t(), String.t()) ::
  {:ok, [Sagents.FileSystem.FileEntry.t()]} | {:error, term()}

Moves a file or directory (and its children) from one path to another.

This is an atomic re-key operation — it does not trigger delete_from_storage or create new entries. If the persistence module implements move_in_storage/3, that callback is invoked for each moved entry. Otherwise, entries are marked dirty and persisted via the normal cycle.

Returns {:ok, moved_entries} or {:error, reason}.

Examples

iex> :ok = write_file({:user, 1}, "/old-name", "content")
iex> {:ok, _entries} = move_file({:user, 1}, "/old-name", "/new-name")
iex> {:ok, entry} = read_file({:user, 1}, "/new-name")
iex> entry.content
"content"

read_file(scope_key, path)

@spec read_file(term(), String.t()) ::
  {:ok, Sagents.FileSystem.FileEntry.t()} | {:error, term()}

Read a file entry from filesystem with lazy loading.

Returns the full FileEntry struct with content loaded. Application code can use all fields; the LLM tool layer extracts .content for the model.

Returns

  • {:ok, %FileEntry{}} - Full file entry with content loaded
  • {:error, :enoent} - File doesn't exist
  • {:error, reason} - Other errors (permission, load failure, etc.)

Examples

iex> {:ok, entry} = read_file({:user, 123}, "/Memories/notes.txt")
iex> entry.content
"My notes..."

register_files(scope_key, file_entry)

@spec register_files(
  term(),
  Sagents.FileSystem.FileEntry.t() | [Sagents.FileSystem.FileEntry.t()]
) ::
  :ok

Register file entries in the filesystem.

Useful for pre-populating the filesystem with file metadata. Accepts either a single FileEntry or a list of FileEntry structs.

Parameters

  • scope_key - Scope identifier tuple
  • file_entry_or_entries - FileEntry struct or list of FileEntry structs

Returns

  • :ok on success

Examples

iex> {:ok, entry} = FileEntry.new_memory_file("/scratch/temp.txt", "data")
iex> FileSystemServer.register_files({:user, 123}, entry)
:ok

iex> {:ok, entry1} = FileEntry.new_memory_file("/scratch/file1.txt", "data1")
iex> {:ok, entry2} = FileEntry.new_memory_file("/scratch/file2.txt", "data2")
iex> FileSystemServer.register_files({:user, 123}, [entry1, entry2])
:ok

register_persistence(scope_key, config)

@spec register_persistence(term(), Sagents.FileSystem.FileSystemConfig.t()) ::
  :ok | {:error, term()}

Register a new persistence configuration.

Allows dynamically adding persistence backends for different base directories.

Parameters

  • scope_key - Scope identifier tuple
  • config - FileSystemConfig struct

Returns

  • :ok on success
  • {:error, reason} if base_directory already registered

Examples

iex> config = FileSystemConfig.new!(%{
...>   base_directory: "user_files",
...>   persistence_module: MyApp.Persistence.Disk,
...>   storage_opts: [path: "/data/users"]
...> })
iex> FileSystemServer.register_persistence({:user, 123}, config)
:ok

reset(scope_key)

@spec reset(term()) :: :ok

Reset the filesystem to pristine persisted state.

This operation:

  • Removes all memory-only files (not persisted)
  • Unloads all persisted files (discards in-memory modifications)
  • Cancels all pending debounce timers (discards unsaved changes)

Result: Next read will reload persisted files from storage in their original state.

This is useful when resetting to start fresh without carrying over transient in-memory file modifications.

Examples

iex> FileSystemServer.reset({:user, 123})
:ok

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Start FileSystemServer for a scope.

Options

  • :scope_key - Scope identifier (required) - Can be any term that uniquely identifies the scope
    • Tuple: {:user, 123}, {:agent, uuid}, {:project, id}
    • UUID: "550e8400-e29b-41d4-a716-446655440000"
    • Database ID: 12345 or "12345"
  • :configs - List of FileSystemConfig structs (optional, default: [])

Examples

# Memory-only filesystem with tuple scope
{:ok, pid} = start_link(scope_key: {:user, 123})

# Memory-only filesystem with UUID
{:ok, pid} = start_link(scope_key: "550e8400-e29b-41d4-a716-446655440000")

# Memory-only filesystem with database ID
{:ok, pid} = start_link(scope_key: 789)

# With disk persistence
{:ok, config} = FileSystemConfig.new(%{
  base_directory: "Memories",
  persistence_module: Sagents.FileSystem.Persistence.Disk,
  debounce_ms: 5000,
  storage_opts: [path: "/data/users/123"]
})
{:ok, pid} = start_link(
  scope_key: {:user, 123},
  configs: [config]
)

stats(scope_key)

@spec stats(term()) :: {:ok, map()}

Get filesystem statistics.

Returns map with various statistics about the filesystem state.

subscribe(scope_key)

@spec subscribe(term()) :: :ok | {:error, :no_pubsub | :process_not_found}

Subscribe to file change events for a filesystem scope.

Events broadcast (wrapped in {:file_system, event} tuple):

  • {:file_system, {:file_updated, path}} - File was created or updated at path
  • {:file_system, {:file_deleted, path}} - File was deleted at path

Examples

# Subscribe to user's filesystem
:ok = FileSystemServer.subscribe({:user, 123})

# Receive events
receive do
  {:file_system, {:file_updated, path}} -> IO.puts("File updated: #{path}")
  {:file_system, {:file_deleted, path}} -> IO.puts("File deleted: #{path}")
end

unsubscribe(scope_key)

@spec unsubscribe(term()) :: :ok | {:error, :no_pubsub | :process_not_found}

Unsubscribe from file change events for a filesystem scope.

update_custom_metadata(scope_key, path, custom, opts \\ [])

@spec update_custom_metadata(term(), String.t(), map(), keyword()) ::
  {:ok, Sagents.FileSystem.FileEntry.t()} | {:error, term()}

Update only the metadata.custom map for a file entry.

Merges the given custom map into the entry's existing metadata.custom. Does not touch content or entry-level fields.

Persists immediately by default. Pass persist: :debounce to opt into debounced persistence instead.

Options

  • :persist - :debounce to schedule a debounced persist instead of persisting immediately. Default is immediate.

Examples

iex> {:ok, entry} = update_custom_metadata({:user, 123}, "/doc.md", %{tags: ["draft"]})
iex> entry.metadata.custom
%{tags: ["draft"]}

update_entry(scope_key, path, attrs, opts \\ [])

@spec update_entry(term(), String.t(), map(), keyword()) ::
  {:ok, Sagents.FileSystem.FileEntry.t()} | {:error, term()}

Update entry-level fields on a file entry.

Accepts a map of attrs with keys :title, :id, and/or :file_type. Uses FileEntry.update_entry_changeset/2 for validation.

Persists immediately by default. Pass persist: :debounce to opt into debounced persistence instead.

Options

  • :persist - :debounce to schedule a debounced persist instead of persisting immediately. Default is immediate.

Examples

iex> {:ok, entry} = update_entry({:user, 123}, "/doc.md", %{title: "My Doc"})
iex> entry.title
"My Doc"

whereis(scope_key)

@spec whereis(term()) :: pid() | nil

Get the FileSystemServer PID by scope key.

The scope_key can be any term that uniquely identifies the filesystem scope. Common patterns include tuples like {:user, 123}, UUIDs like "550e8400-e29b-41d4-a716-446655440000", or database IDs like 12345.

write_file(scope_key, path, content, opts \\ [])

@spec write_file(term(), String.t(), String.t(), keyword()) ::
  {:ok, Sagents.FileSystem.FileEntry.t()} | {:error, term()}

Write content to a file path.

For existing files, preserves existing metadata (custom, created_at, etc.) and only updates content-related fields. For new files, creates a fresh entry.

Returns the updated FileEntry on success.

Options

  • :custom - Custom metadata map
  • :mime_type - MIME type string
  • :title - Human-readable title

Examples

iex> {:ok, entry} = write_file({:user, 123}, "/tmp/notes.txt", "Hello")
iex> entry.content
"Hello"