/** * artifacts/s3Artifacts — the claim-check store in an S3 bucket. * * The third rung of the same ladder: `inMemoryArtifacts` for a test, * `fileArtifacts`/`sqliteArtifacts` for one machine, this for a fleet. Nothing * about the five verbs changes — a tool that stored a dataset against the * in-memory store stores it here by swapping one constructor, because the port * is the contract and this file is only a translation of it. * * Read `objectStore.ts` first: it holds the five laws both object adapters * obey (payload as canonical bytes in the body, ONE metadata entry carrying * the ticket, a checked metadata budget, 404-means-null-everything-else-means- * error, service-time ordering). What is genuinely S3's, and therefore lives * here: * * ── The object key ────────────────────────────────────────────────────────── * `[/]///` — the scope * partitioned into key segments by `scopePath.ts`, the same percent-encoding * law the directory adapter uses, so a tenant of literally `'..'` is a NAME in * both. A ref alone opens nothing: a wrong scope computes a different key, * S3 answers 404, and the caller reads `null` — one indistinguishable miss, * never a cross-tenant read. * * ── The ticket rides as object metadata, not a sidecar ────────────────────── * `x-amz-meta-af-artifact`, one entry, ASCII JSON. Two reasons this beats a * second `.meta.json` object: `head()` becomes ONE HeadObject (the port * promises the render-by-ref decision without paying for the payload, and a * sidecar would make it a second GET), and `get()` returns the ticket AND the * bytes in a single GetObject — a sidecar would make every read two round * trips and open a window where one exists and the other does not. * * The cost is S3's cap: **2 KB of user metadata per object**, checked at put * and refused by name. A ticket is small (a ref, a kind, a media type, sizes, * a label); it only reaches 2 KB if the label is prose or `parentRefs` is a * bibliography, and the refusal says which. * * ── What a 404 is allowed to mean here ────────────────────────────────────── * S3 answers 404 for two different sentences: "no such key" and "no such * bucket". Only the first is the port's "no data", so this adapter reads the * CODE and not the status, and only the calls that named one object — * Head/Get/Delete — may read a 404 as `null` at all. A 404 from PutObject or * ListObjectsV2 means the bucket is not there; it goes through the sanitizer * like every other failure, because nothing downstream is waiting to turn it * into `null` and the SDK's text for it contains the object key. * * ── What a listing costs, said plainly ────────────────────────────────────── * `ListObjectsV2` returns keys, sizes and `LastModified` — never user * metadata. So `list()` pages the keys (cheap, 1000 per call), sorts by * `LastModified` (newest first, as the port promises), and issues one * `HeadObject` for each row IT RETURNS — not for the whole scope. An expired * object inside a page is dropped from that page rather than backfilled, so a * page can come back short while more pages remain. That is the honest trade; * an "index object" listing every ticket in a scope would be cheaper and * would be a lost-update race between two writers, which is worse than a * round trip. * * ── Retention, and the operator's bulk tool ───────────────────────────────── * The dials work exactly as everywhere else: `ttlMs` stamps `expiresAt` AT * MINT (stated, never sprung), and budgets evict oldest-first (S3 has no cheap * read-recency, the same statement `fileArtifacts` makes). Two S3-specific * facts: * * • **Expiry is enforced on READ.** An expired object answers `null` from * `head`/`get` and is deleted on the way past (a sweep on access), so an * expired ticket can never be redeemed even if the object is still there. * • **A put only SCANS the scope when a budget is configured.** With no * `maxBytesPerScope`/`maxCountPerScope` there is nothing to plan, so a put * is exactly one `PutObject` — and paying for a full scope listing on * every write would be a real bill for no answer. * * Which leaves reclamation of storage for artifacts nobody reads again — and * that is what **S3 Lifecycle rules** are for. They are the operator's bulk * tool and this adapter does not create them (a lifecycle rule is a cost and * compliance decision that belongs to your infrastructure). Align them like * this: * * ```jsonc * // Expire objects 7 days after creation, under the store's prefix. * // Keep the rule LONGER than the store's ttlMs, never shorter: * // the store's expiresAt is the promise consumers read on the ticket, and a * // lifecycle rule that deletes first makes a live ticket resolve to null * // BEFORE the time it printed. Longer, and lifecycle is what it should be: * // the backstop that reclaims what the store's own sweep never revisited. * { "Rules": [{ "ID": "agentfootprint-artifacts", "Status": "Enabled", * "Filter": { "Prefix": "artifacts/" }, * "Expiration": { "Days": 7 } }] } * ``` * * ── Lazy peer dependency ──────────────────────────────────────────────────── * `@aws-sdk/client-s3` is an OPTIONAL peer dependency, required at * CONSTRUCTION (the sqliteSessions law): importing the barrel costs a browser * bundle nothing, and a missing install refuses where the config was written * rather than at the first put of the first run. Pass `client` to share the * SDK configuration your app already has. * * ── Status ────────────────────────────────────────────────────────────────── * Contract-shaped and tested; awaiting field use. The five commands it * dispatches are pinned by `test/adapters/aws/aws-command-pin.test.ts`; the * behavior is proved against an emulation double that speaks the same five. * No live call has been made from this repository. */ import { type ArtifactRetention } from './retention.js'; import { type ArtifactStore } from './types.js'; /** A `*Client` as this adapter uses it: `send`, and nothing else. */ export interface S3ArtifactsClientLike { send(command: unknown): Promise; } /** The five command constructors this adapter dispatches. */ export interface S3ArtifactsSdkModule { readonly S3Client?: new (config: { region?: string; }) => S3ArtifactsClientLike; readonly PutObjectCommand?: new (input: unknown) => unknown; readonly GetObjectCommand?: new (input: unknown) => unknown; readonly HeadObjectCommand?: new (input: unknown) => unknown; readonly DeleteObjectCommand?: new (input: unknown) => unknown; readonly ListObjectsV2Command?: new (input: unknown) => unknown; } /** Options for {@link s3Artifacts}. */ export interface S3ArtifactsOptions { /** The bucket. It must already exist — this library never creates one. */ readonly bucket: string; /** Key prefix inside the bucket, so a bucket can be shared. The scope * layout starts under it. Absent = at the root of the bucket. */ readonly prefix?: string; /** Region for the client this factory builds. Ignored when `client` is * passed — that client's configuration is yours. */ readonly region?: string; /** Your own pre-built client; configuration and credentials stay yours. */ readonly client?: S3ArtifactsClientLike; /** Retention dials. Budgets evict OLDEST-first here: S3 has no cheap * read-recency, the same statement `fileArtifacts` makes about a * directory. */ readonly retention?: ArtifactRetention; /** @internal Test seam — the SDK module, injected. */ readonly _sdk?: S3ArtifactsSdkModule; /** @internal Test seam — a client injected past the SDK entirely. */ readonly _client?: S3ArtifactsClientLike; /** @internal Test seam — the clock. Defaults to `Date.now`. */ readonly _now?: () => number; } /** * An artifact store in an S3 bucket. * * @example * const store = s3Artifacts({ bucket: 'my-agent-artifacts', prefix: 'artifacts' }); * const agent = Agent.create({ provider, artifacts: store }); */ export declare function s3Artifacts(options: S3ArtifactsOptions): ArtifactStore; //# sourceMappingURL=s3Artifacts.d.ts.map