Docs menu
Retention TTL sweep
Bulk TTL sweep (sweepRuns/sweepLog/sweepThreads) that permanently deletes runs/log entries/threads older than a given age, plus period rollover (rolloverRun) for long-lived runs — triggered from Studio's /retention/sweep endpoint.
What it's for / when to use it#
Because the journal is append-only, runs accumulate over time — storage cost and read load grow. sweepRuns permanently deletes runs older than a given age (olderThanMs), measured from their last activity. Typical scenario: wiring a retention policy like "clean up completed runs older than 30 days every night" into the host's cron, or triggering it manually from Studio with POST /retention/sweep. The safety defaults are deliberately conservative: suspended runs (awaiting approval) and runs with no timestamp are silently not deleted.
Setup / import#
import { sweepRuns, sweepLog, sweepThreads, rolloverRun } from '@gnldev/durable';There's no separate subpackage path — sweepRuns, sweepLog, sweepThreads, rolloverRun, purgeRun, and purgeThread all come directly from @gnldev/durable's root export. Sweeping requires the journal's deletePrefix port (provided by the InMemory/Sqlite/Postgres/Redis adapters) and its read surface (listRuns/readRun/listKeys); if either is missing, it throws a clear error.
Step-by-step usage#
1. Sweep runs older than a given age directly from code:
import { sweepRuns } from '@gnldev/durable';
const result = await sweepRuns(journal, { olderThanMs: 30 * 86_400_000 }); // 30 days
// result: { scanned, purged, keptSuspended, keptNoTs, deletedEntries }Age is measured by the last timestamp among the run's entries (last activity). keepSuspended defaults to true, so a run awaiting approval is never swept regardless of its age. Runs with no timestamp at all also stay on the safe side and are reflected in the keptNoTs counter.
2. Trigger the same sweep over HTTP via Studio (e.g. from a cron job):
POST /retention/sweep
{ "olderThanMs": 2592000000, "keepSuspended": true }
// or, when createStudioApp was given { retention: { olderThanMs, keepSuspended } },
// the body may be left empty — those defaults are then used.
POST /retention/sweep
{}403. If the journal doesn't support deletePrefix, the endpoint fails with 501.3. Before deletion, if a run has already been added to a budget counter (__usage__), its cost is automatically deducted — so the organization's usage summary doesn't accumulate ghost cost after a sweep. Every successful sweep is written to the Studio audit log as retention.sweep, along with the scanned/deleted counts.
4. sweepRuns only sees runs — durable-log namespaces (e.g. Studio's __audit__/__alert__ records) and BasicMemory threads (mem:<threadId>:*) grow unbounded in long-lived deployments. sweepLog closes that gap — it sweeps a namespace based on the record's at field:
import { sweepLog } from '@gnldev/durable';
const result = await sweepLog(journal, '__audit__', {
olderThanMs: 90 * 86_400_000, // 90 days
// the marker schema is callback-defined — it is not discovered automatically (bkz. @gnldev/queue/@gnldev/events consumeOnce)
markerFor: (item) => `ack:worker1:${item.id}`,
});
// result: { scanned, deleted, keptNoTs, deletedMarkers }5. sweepThreads bulk-deletes BasicMemory threads whose last message is older than the threshold, using purgeThread — threads whose ts can't be read (messages with no timestamp) stay on the safe side and aren't deleted:
import { sweepThreads } from '@gnldev/durable';
const result = await sweepThreads(journal, { olderThanMs: 180 * 86_400_000 }); // 180 days
// result: { scanned, purged: string[], keptNoTs }Long-lived runs: period rollover (rolloverRun)#
Because the journal is append-only it can't be truncated — for a SINGLE run living for weeks or months, that means replay would read from the start every time and the journal would grow without bound. rolloverRun solves this by splitting the run into logical periods: at period close, the old run's materialized final state (messages) is carried into the :input seed of a new runId; the new period starts with a zero-length journal, continuing from that context. This is the by-design counterpart to in-place compaction (deliberately absent — the append-only contract); the old journal is left untouched, and deletion is a separate decision (sweepRuns above).
import { rolloverRun, resumeRun } from '@gnldev/durable';
// Period N is complete:
const r = await rolloverRun(journal, 'agent'); // -> { newRunId: 'agent@2', seededMessages, messages }
// Period N+1 continues — the new runId's :input is already seeded, so the prompt is not supplied again:
await resumeRun(r.newRunId, { journal, model, tools });Both the target newRunId (defaults to `${runId}@2`, then @3...) and the seed (:input) are written via claim (CAS): trying to roll over the same old run a second time returns the EXISTING target (deterministic, idempotent) — of two concurrent calls, only one determines the target. To carry a long conversation forward as a summary, pass carry (if omitted, messages are carried over as-is):
const r = await rolloverRun(journal, 'agent', {
carry: async (messages) => [{ role: 'system', content: await summarize(messages) }],
});messages to the new period's FIRST call that differ from the seed — since :input is already populated, the difference is never written to the journal, and the next resume will never see those messages. Rolling over a period that has a suspended tool works structurally but isn't recommended; do the rollover when there's no pending suspension, at period close.API reference#
sweepRuns(journal, opts: SweepOptions) → Promise<SweepResult>. Scans and permanently deletes runs whose last activity is older than olderThanMs; requires the read surface (listRuns/readRun) and deletePrefix.
SweepOptions{ olderThanMs: number, keepSuspended?: boolean (default true), now?: number ("now" for testing) }.
SweepResult{ scanned, purged: string[], keptSuspended, keptNoTs, deletedEntries } — how many runs were scanned, which were deleted, how many were kept.
purgeRun(journal, runId) → Promise<number>. The single-run deletion helper used internally by sweepRuns: permanently deletes all of the run's trace (<runId>:* + memory marker); it is RECURSIVE — it cascade-deletes the journals of sub-agent/network children too, at any depth.
sweepLog(journal, ns, opts: LogSweepOptions) → Promise<LogSweepResult>. Sweeps a durable-log namespace (e.g. Studio's __audit__/__alert__) based on the record's `at` field; requires listKeys + deletePrefix.
LogSweepOptions{ olderThanMs, markerFor?: (item) => string|string[]|undefined, now? } — since the consume-marker schema isn't fixed, markerFor is supplied by the caller.
LogSweepResult{ scanned, deleted, keptNoTs, deletedMarkers }.
sweepThreads(journal, opts: ThreadSweepOptions) → Promise<ThreadSweepResult>. Bulk-deletes BasicMemory threads whose last message is older than the threshold, using purgeThread; threads whose ts can't be read are not deleted.
ThreadSweepOptions{ olderThanMs, now? }.
ThreadSweepResult{ scanned, purged: string[], keptNoTs }.
rolloverRun(journal, runId, opts?: RolloverOptions) → Promise<RolloverResult>. Carries a long-lived run's final state into a new runId's :input seed (period rollover) — deterministic target + idempotent seed, deletes nothing.
RolloverOptions{ newRunId?, carry?: (messages) => messages|Promise<messages> } — if carry is omitted, messages are carried over as-is.
RolloverResult{ newRunId, seededMessages, messages } — the new runId and the messages seeded into it.
sweepRuns/purgeRun with a journal adapter that doesn't support `deletePrefix` throws a clear error rather than silently deleting nothing.