AgentSessionManager.Adapters.S3ArtifactStore stores binary artifacts in
S3-compatible object storage. Use it for workspace patches, snapshot manifests,
and any large blobs that should not live in a relational database. It works with
AWS S3, MinIO, DigitalOcean Spaces, Backblaze B2, and any other service that
implements the S3 API.
Prerequisites
Add the ExAws dependencies to your mix.exs:
def deps do
[
{:agent_session_manager, "~> 0.8.0"},
{:ex_aws, "~> 2.5"},
{:ex_aws_s3, "~> 2.5"},
{:sweet_xml, "~> 0.7"}, # required by ex_aws for XML response parsing
{:hackney, "~> 1.20"} # HTTP client for ex_aws
]
endConfigure ExAws with your credentials. The simplest approach is environment variables:
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=us-east-1
Or configure in config/config.exs:
config :ex_aws,
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role],
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role],
region: "us-east-1"For S3-compatible services (MinIO, DigitalOcean Spaces, etc.), set the endpoint:
config :ex_aws, :s3,
scheme: "https://",
host: "nyc3.digitaloceanspaces.com",
region: "nyc3"Configuration
Start the store with a bucket name and optional prefix:
alias AgentSessionManager.Adapters.S3ArtifactStore
# Minimal
{:ok, store} = S3ArtifactStore.start_link(bucket: "my-artifacts")
# With a key prefix and registered name
{:ok, store} = S3ArtifactStore.start_link(
bucket: "my-artifacts",
prefix: "asm/artifacts/",
name: :artifact_store
)
# With a custom S3 client (see below)
{:ok, store} = S3ArtifactStore.start_link(
bucket: "my-artifacts",
client: MyApp.MockS3Client
)Options
| Option | Required | Default | Description |
|---|---|---|---|
:bucket | Yes | -- | S3 bucket name |
:prefix | No | "" | Key prefix prepended to all object keys |
:client | No | S3ArtifactStore.ExAwsClient | Module implementing the S3Client behaviour |
:name | No | -- | GenServer name for registration |
Key Structure
Objects are stored at {prefix}{key}. For example, with
prefix: "asm/artifacts/" and key "patch-abc123", the full S3 object key is:
asm/artifacts/patch-abc123Using a prefix keeps all AgentSessionManager artifacts organized under a common path and avoids collisions with other data in the same bucket.
Supervision Tree
children = [
{S3ArtifactStore,
bucket: "my-artifacts",
prefix: "asm/",
name: :artifact_store}
]
Supervisor.start_link(children, strategy: :one_for_one)The S3Client Behaviour
The adapter delegates actual S3 API calls to a module that implements
AgentSessionManager.Adapters.S3ArtifactStore.S3Client:
@callback put_object(bucket :: String.t(), key :: String.t(), data :: binary(), opts :: keyword()) ::
:ok | {:error, term()}
@callback get_object(bucket :: String.t(), key :: String.t(), opts :: keyword()) ::
{:ok, binary()} | {:error, term()}
@callback delete_object(bucket :: String.t(), key :: String.t(), opts :: keyword()) ::
:ok | {:error, term()}The default implementation, S3ArtifactStore.ExAwsClient, uses ExAws.S3 and
ExAws.request() under the hood. It maps a 404 HTTP response to
{:error, :not_found} for clean error handling.
Custom Client for Testing
Provide a mock or stub implementation for tests:
defmodule MyApp.TestS3Client do
@behaviour AgentSessionManager.Adapters.S3ArtifactStore.S3Client
@impl true
def put_object(_bucket, key, data, _opts) do
Agent.update(:test_s3, fn state -> Map.put(state, key, data) end)
:ok
end
@impl true
def get_object(_bucket, key, _opts) do
case Agent.get(:test_s3, fn state -> Map.get(state, key) end) do
nil -> {:error, :not_found}
data -> {:ok, data}
end
end
@impl true
def delete_object(_bucket, key, _opts) do
Agent.update(:test_s3, fn state -> Map.delete(state, key) end)
:ok
end
endUse it in tests:
Agent.start_link(fn -> %{} end, name: :test_s3)
{:ok, store} = S3ArtifactStore.start_link(
bucket: "test-bucket",
client: MyApp.TestS3Client
)You can also use Mox to define expectations:
Mox.defmock(MockS3Client, for: AgentSessionManager.Adapters.S3ArtifactStore.S3Client)
{:ok, store} = S3ArtifactStore.start_link(
bucket: "test-bucket",
client: MockS3Client
)
expect(MockS3Client, :put_object, fn _bucket, _key, _data, _opts -> :ok end)Usage Examples
alias AgentSessionManager.Adapters.S3ArtifactStore
alias AgentSessionManager.Ports.ArtifactStore
{:ok, store} = S3ArtifactStore.start_link(
bucket: "my-artifacts",
prefix: "asm/"
)
# Store an artifact
patch_data = "--- a/file.ex\n+++ b/file.ex\n@@ -1 +1 @@\n-old\n+new"
:ok = ArtifactStore.put(store, "patch-abc123", patch_data)
# Retrieve it
{:ok, data} = ArtifactStore.get(store, "patch-abc123")
data
#=> "--- a/file.ex\n+++ b/file.ex\n@@ -1 +1 @@\n-old\n+new"
# Delete it
:ok = ArtifactStore.delete(store, "patch-abc123")
# Deleting a non-existent key is a no-op
:ok = ArtifactStore.delete(store, "does-not-exist")
# Retrieving a missing key returns an error
{:error, %AgentSessionManager.Core.Error{code: :not_found}} =
ArtifactStore.get(store, "does-not-exist")Composing with a SessionStore
A common production pattern is to store sessions in a database and artifacts in
S3. Use CompositeSessionStore to combine them:
alias AgentSessionManager.Adapters.{EctoSessionStore, S3ArtifactStore, CompositeSessionStore}
{:ok, session_store} = EctoSessionStore.start_link(repo: MyApp.Repo)
{:ok, artifact_store} = S3ArtifactStore.start_link(bucket: "my-artifacts")
{:ok, store} = CompositeSessionStore.start_link(
session_store: session_store,
artifact_store: artifact_store,
name: :unified_store
)The composite store implements both SessionStore and ArtifactStore
behaviours, so it can be used anywhere either port is expected.
Notes and Caveats
S3-compatible means any service that speaks the S3 API. Configure the endpoint in your ExAws config to point at MinIO, DigitalOcean Spaces, or any other compatible service.
Binary data only. The
put/4callback acceptsiodata()and converts it to a binary withIO.iodata_to_binary/1before upload. Theget/3callback returns a plainbinary().No streaming. The current implementation uploads and downloads entire objects in memory. For very large artifacts (hundreds of megabytes), consider a custom
S3Clientimplementation that uses multipart uploads.Idempotent deletes. Deleting a key that does not exist returns
:ok, matching the ArtifactStore contract.ExAws must be configured. The default
ExAwsClientcallsExAws.request(), which reads credentials from application config and environment variables. Make sure ExAws is properly configured before starting the store.Error wrapping. S3 errors are wrapped in
%AgentSessionManager.Core.Error{code: :storage_error}. A 404 from S3 is converted to%Error{code: :not_found}.