Temp Storage Garbage Collection
Problem
The runtime writes workflow media outputs to the temp/ storage prefix and never deletes them.
ProcessingContextwritestemp/<uuid>.<ext>for everytemp_urloutput (packages/runtime/src/context.ts, themode === "temp_url"branch). Workflow jobs and chat turns select this mode for every non-text session (packages/websocket/src/session/job-execution.ts,packages/websocket/src/session/chat-turn.ts).- The SDK temporary-asset service writes
temp/sdk-inputs/<id>.<ext>and returnsexpires_at: null(packages/websocket/src/sdk/sdk-temporary-asset-service.ts). - No code lists or deletes keys under
temp/. The retention sweep (cleanupStorageinpackages/models/src/storage-maintenance.ts) deletes database rows only.
On the file backend, temp/ shares the assets directory
(~/.local/share/nodetool/assets/temp). One developer machine measured
3.9 GB in 20,561 files. On S3 and Supabase, TEMP_BUCKET has no documented
lifecycle rule (docs/storage.md).
Durable references to temp keys
A temp URL is meant to be short-lived, but some durable rows store it. A measurement on one local SQLite database found:
| Table | Rows | Distinct temp refs | Refs with an asset_id |
|---|---|---|---|
storyboards.document |
3 | 20 | 19 |
nodetool_jobs.graph |
16 | 13 | 1 |
nodetool_jobs.metadata_json |
1 | not measured | not measured |
nodetool_workflows.graph |
0 | 0 | 0 |
nodetool_assets (all columns) |
0 | 0 | 0 |
No asset row points at temp/. An asset owns a separate file
(<assetId>.<ext>, or <userId>/<assetId>.<ext> in the new layout). A temp
ref that carries an asset_id can therefore switch to the asset without data
loss.
Storyboards leak temp URLs because packages/storyboard/src/io/render-shots.ts
copies asset.uri next to asset_id into KeyframeVersion and
ClipVersion. The asset_id is the durable locator, so the uri is redundant
and expires.
Decisions
D1. Sweep by age through the storage adapter
Add sweepTempStorage to packages/storage. It takes a StorageAdapter, a
cutoff time, and a pin predicate. It uses the existing list("temp/"),
stat, and delete methods, so it works on the file, S3, Supabase, and
in-memory backends without backend branches.
interface TempSweepOptions {
olderThanMs: number; // age threshold, measured on StorageEntry.modifiedAt
now: number; // injected for tests
isPinned: (key: string) => boolean;
dryRun: boolean;
maxDeletes?: number; // bound one pass
}
interface TempSweepResult {
scanned: number;
deleted: number;
pinned: number;
bytesFreed: number;
errors: number;
}
Rules:
- Consider only keys that start with
temp/. Refuse any other prefix. - Delete an entry only when
now - modifiedAt >= olderThanMsand it is not pinned. - Continue after a failed delete. Count it in
errors. - Delete with bounded concurrency. Log one summary line per pass.
D2. One global retention window, not a per-user setting
The temp/ prefix has no owner segment. Keys from all users share it, so a
per-user policy cannot decide a key’s lifetime. Use one server-wide value:
NODETOOL_TEMP_RETENTION_HOURS, default 168 (7 days), minimum 24. The
minimum protects running jobs and open editor sessions, which fetch a temp URL
minutes after the write.
Register the variable in packages/websocket/src/settings-registry.ts and
document it in docs/storage.md.
D3. Pin temp keys that durable rows still reference
Before a pass, build a set of temp keys from durable rows across all users:
storyboards.documentnodetool_workflows.graphandnodetool_workflow_versions.graphnodetool_jobs.graphandmetadata_jsonfor jobs that are not terminal
Terminal jobs do not pin. The job retention window (terminalJobRetentionDays)
already bounds their lifetime, and a finished run’s preview is scratch output.
Extract keys with one pattern, temp/(sdk-inputs/)?[0-9a-f-]{32,36}\.[a-z0-9]+,
from the raw column text. Put the scan in packages/models next to
storage-maintenance.ts, because it reads model tables. Scan in pages. Do
not load all documents into memory at once.
The pin set is a safety net during migration. D4 removes the cause.
D4. Store the asset locator, not the temp URL
Change render-shots.ts to write uri: \asset://${assetId}` on keyframe and
clip versions. Check other writers that copy a run output uri next to an
asset_id` into a durable document, and fix them the same way.
Add a one-time data migration: in storyboards.document, replace a
/api/storage/temp/... uri with asset://<asset_id> when the same object
carries an asset_id and that asset row exists. Leave refs without an
asset_id unchanged. D3 keeps them pinned.
D5. Run the sweep with the existing maintenance timer
Call the sweep from the 6-hour runHistoryCleanup timer in
packages/websocket/src/server.ts, through a new function in
packages/websocket/src/storage-retention.ts. The temp adapter comes from
getTempAdapter() (packages/websocket/src/lib/storage.ts). Run one pass at
startup, like the history cleanup. Log a failure and continue. A sweep error
must not stop the server.
D6. Give the sweep a headless surface
Add a CLI command, for example nodetool storage temp-gc [--dry-run]
[--older-than <hours>], that prints the TempSweepResult. Register a
selfcheck in packages/cli/src/harness/registry.ts per
Harness-First Engineering. Follow the existing CLI
command layout in packages/cli/src/.
D7. Make the SDK expiry contract true
Set expires_at in the SDK temporary-asset upload response to the upload time
plus the retention window. Update the protocol schema if it requires null.
Cloud backends
The adapter sweep (D1) works on S3 and Supabase. Verify that list in
s3-storage-adapter.ts and supabase-storage-adapter.ts follows continuation
tokens. A single unpaginated call returns at most 1,000 keys, and the sweep
then misses everything after that. Also add a bucket lifecycle rule on
TEMP_BUCKET that expires objects after 30 days, as a backstop for a stopped
sweep. Document the rule in docs/storage.md and
docs/fly-production-deploy.md.
Tests
| Test | Location | Asserts |
|---|---|---|
| Sweep age cutoff | packages/storage/tests/ |
Deletes old keys, keeps new keys, with injected now. |
| Sweep prefix guard | packages/storage/tests/ |
Never deletes a key outside temp/, even when it is old. |
| Sweep pins and dry run | packages/storage/tests/ |
Pinned keys survive. Dry run deletes nothing and reports the same counts. |
| Sweep pagination | packages/storage/tests/ |
More than 1,000 entries are all scanned (adapter stub). |
| Pin scan | packages/models/tests/ |
Finds keys in a storyboard, a workflow graph, and a running job. Ignores a terminal job. |
| Storyboard migration | packages/models/tests/ |
Rewrites a ref with asset_id to asset://. Keeps a ref without one. |
| Render writer | packages/storyboard/tests/ |
New keyframe and clip versions carry asset://<id>. |
Prove each new test fails against the unfixed code before you keep it.
Risks
- R1. A client holds a temp URL longer than the window, for example a tab left open for a week. The preview breaks. The job result is still in history only as a dead link. The 7-day default makes this rare.
- R2. A durable writer that D3 does not scan stores a temp URL. The sweep
then deletes a file the row still needs. Mitigation: search for writers
during D4, and run the first production pass with
--dry-run, then compare the delete list against a full-text search of the database. - R3. A pin scan over large JSON columns is slow on PostgreSQL. Scan in
pages, and filter with
LIKE '%temp/%'before the regex.
Open questions
- Q1. Is 7 days the right default window, or is 24 hours enough for the hosted service?
- Q2. Should refs without an
asset_id(1 storyboard ref and 12 job refs on the measured machine) stay pinned forever, or expire after a longer window?
Implementation Prompt
Give this section to the implementing agent. It builds on the decisions above.
Tasks
Do the tasks in this order. Each task must leave the tree green.
- Read
AGENTS.md,packages/AGENTS.md, and the storage, models, websocket, and cli package overlays that apply. - Reproduce the leak. Write a failing test in
packages/storyboard/tests/that renders a keyframe throughrender-shots.tsand asserts the storeduriisasset://<assetId>. Then fix the writer (D4). Search for other writers that copy a run outputurinext to anasset_idinto a durable document, and fix them the same way. - Add
sweepTempStoragetopackages/storage(D1). Use only theStorageAdapterinterface. Refuse keys outsidetemp/. Injectnow. Write the storage tests from the Tests table first. - Verify that
listin the S3 and Supabase adapters follows continuation tokens. If it does not, write a failing test, then fix the pagination. - Add the pin scan to
packages/models(D3). Scan across all users, page through rows, and pin only non-terminal jobs. Add its tests. - Add the one-time storyboard migration (D4) through the existing migration
mechanism in
packages/models. Rewrite a tempuritoasset://<asset_id>only when the asset row exists. - Add
NODETOOL_TEMP_RETENTION_HOURS(default 168, minimum 24) topackages/websocket/src/settings-registry.ts(D2). Wire one sweep pass into the startup call and the 6-hourrunHistoryCleanuptimer inpackages/websocket/src/server.ts, throughpackages/websocket/src/storage-retention.ts(D5). A sweep failure logs a warning and never stops the server. - Add the CLI command with
--dry-runand--older-than <hours>, and register its selfcheck inpackages/cli/src/harness/registry.ts(D6). - Set
expires_atin the SDK temporary-asset upload response (D7). - Update
docs/storage.mdwith the retention variable, the sweep, and the recommendedTEMP_BUCKETlifecycle rule. Updatedocs/fly-production-deploy.mdif it lists bucket configuration.
Constraints
- Deliver only this scope. Do not refactor the storage adapters or the retention settings UI.
- Do not add a per-user temp retention setting. The
temp/prefix has no owner segment (D2). - Do not branch on the storage backend inside the sweep.
- Prove each new test fails against the unfixed code, then restore the fix.
Use
git checkout <base> -- <files>for the red proof, notgit stash. - Do not run the sweep against a real user data directory. Use temp
directories and
InMemoryStorageAdapterin tests. - Do not add a co-author line to commits.
Verification
Run all four checks from the repository root. All must pass:
npm run test:affected
npm run typecheck
npm run lint
npm run dev:nodetool -- harness gate --base origin/main
Then run the new CLI command with --dry-run against a scratch storage
directory that holds old files, new files, and one file a test storyboard
references. Report the printed counts.
Report
In the final response, state:
- The files changed, grouped by task.
- The red-proof evidence for each new test.
- The output of the four checks.
- The dry-run output.
- Any other durable writer of temp URLs you found, and the fix you applied.
- Your answers or evidence for open questions Q1 and Q2.