Skip to content

Add safe R2 audio maintenance tools - #544

Open
gianpaj wants to merge 27 commits into
mainfrom
feat/cleanup-orphaned-r2-audio-script
Open

Add safe R2 audio maintenance tools#544
gianpaj wants to merge 27 commits into
mainfrom
feat/cleanup-orphaned-r2-audio-script

Conversation

@gianpaj

@gianpaj gianpaj commented Aug 23, 2026

Copy link
Copy Markdown
Owner

This adds guarded tools for two R2 maintenance jobs: inventorying and cleaning old orphaned free-user audio, and backing up complete buckets or exact prefixes to a local drive without overwriting files. The split matters because backup must not inherit the cleanup command's Supabase, age, allowlist, or deletion behavior.

The cleanup command uses reviewed manifests, database-reference checks, a 45-day cutoff, fresh R2 metadata checks, bounded downloads, and explicit deletion confirmation. The backup command lists every requested source before transfer, verifies simple ETags with MD5 and opaque ETags by size, selects missing objects oldest-first under an optional cap, streams four downloads concurrently, and writes a complete JSON report. Interactive transfers use effective-progress with Effect 4 RC; redirected runs avoid Ink rendering.

Validation:

  • pnpm --filter @sexyvoice/scripts test: 39 passed
  • pnpm --filter @sexyvoice/scripts type-check: passed
  • focused Biome checks: passed
  • pnpm fixall: passed with five existing Sentry namespace-import warnings
  • pnpm type-check: passed
  • production dry run: 5,132 objects, 2.1 GiB, zero downloads
  • pnpm test: scripts passed and 62/63 web files passed; 35 Stripe webhook tests fail on the existing closed Redis test connection in tests/utils/redis-test-utils.ts

Documentation updates:

  • scripts/README.md
  • docs/plans/2026-08-22-r2-orphan-audio-cleanup.md
  • docs/plans/2026-08-23-r2-audio-backup-design.md

No migrations, billing, credits, generation behavior, storage deletion policy outside the cleanup command, or public API contracts change.

@gianpaj
gianpaj deployed to Production August 23, 2026 09:02 — with GitHub Actions Active
@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
sexyvoice Ignored Ignored Preview Aug 23, 2026 2:45pm
sexyvoice-docs Ignored Ignored Preview Aug 23, 2026 2:45pm

Request Review

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @gianpaj's task in 6m 59s —— View job


Review: Add safe R2 audio maintenance tools

I read the full diff and cross-checked the cleanup allowlist and key formats against the web app's upload paths. Overall this is careful, defensive work — the safety layering is genuinely good and I found no bug that would cause incorrect deletion. My findings are about structure, robustness on the destructive path, and efficiency, plus one behavioral property that should be documented before it gets "fixed" by a future change.

⚠️ I could not run pnpm install, so pnpm test / pnpm type-check / pnpm fixall were not executed here. Findings below are from static review only. Your PR description reports them green.


What's well done

  • Path safety in scripts/lib/r2-transfer.mts:278 — absolute/Windows-path/./../empty-segment rejection, plus per-component lstat symlink checks, re-validated after mkdir and again before finalize (:390, :436).
  • No-overwrite finalize via link()copyFile(..., COPYFILE_EXCL) (:509) rather than rename.
  • Conditional GET with IfMatch (lib/r2-client.mts:70) mapping 412 → changed, 404 → missing, plus the byte-limit Transform (r2-transfer.mts:491) so a grown object can't blow past its listed size.
  • Delete path re-verifies everything: local checksum → live hasStorageKey → fresh headObject metadata compare → delete (cleanup-orphaned-r2-audio.mts:371-469). Only verified-checksum backups are eligible, so opaque/multipart-ETag objects can never be deleted under --download --delete.
  • I verified the allowlist prefixes are correct: generated-audio-free/ (api/generate-voice/route.ts:486, api/v1/speech/route.ts:545) and cloned-audio-free/ (api/clone-voice/route.ts:1402). storage_key is stored as the exact R2 key (lib/supabase/queries.ts:319, :794), so the DB comparison is apples-to-apples. Clone reference audio goes to clone-voice-input/ (clone-voice/route.ts:1587), correctly outside the allowlist.
  • The lib/env.mts / lib/supabase.mts extraction is behavior-preserving across all five refactored scripts, including persistSession: true for reset-freeloader-credits.

Findings

1. The destructive script can't be imported, so its orchestration is untested — scripts/cleanup-orphaned-r2-audio.mts:37,668

backup-r2-audio.mts does this right: runBackup is exported, takes injected dependencies, and main() is guarded by an entry-point check (:759-767). It gets four end-to-end orchestration tests.

The cleanup script does the opposite: loadScriptEnv() runs at module scope (:37) and main().catch(...) runs unconditionally on import (:668). Nothing is exported. The result is that the only irreversible code path in this PR has zero orchestration coverage — 683 test lines cover the pure library, but nothing covers runAction's delete loop ordering, the --download --delete restriction to backedUp, or report writing.

Applying the same entry-point guard and dependency-injection shape used by the backup script would make these testable without changing behavior. Fix this →

2. A throw mid-delete loses the record of what was deleted — scripts/cleanup-orphaned-r2-audio.mts:367-481

writeActionReport is only reached on the normal path (:481). If anything throws inside the delete loop — or if writeJson's flag: 'wx' / mkdir fails on a full or read-only disk — main().catch prints a message and exits, and the report for already-deleted objects is never written. Deletions are irreversible and the report is the only record of them.

Realistically this needs a disk/permission failure to trigger, but that's exactly the scenario where you most need the record. Wrapping the action body so the report is written in a finally (from whatever results has accumulated) would close it.

3. Deletion is 3 round-trips per object and the batching code is unreachable — scripts/cleanup-orphaned-r2-audio.mts:461

The loop calls deleteCandidatesInBatches(r2, [candidate], 1) once per candidate. Per object that's one hasStorageKey DB query + one headObject + one DeleteObjects call, all serial. Meanwhile DEFAULT_DELETE_BATCH_SIZE = 100, Map.groupBy, and the batch-slicing loop in lib/r2-orphan-audio-cleanup.mts:547-605 are never exercised with more than one key outside tests — a maintenance hazard, since the batching branch looks live but isn't.

The per-object recheck is a deliberate and correct safety tradeoff, but the two can coexist: recheck concurrently in chunks, then delete the survivors in real batches. As written, a few thousand orphans is a very long serial run. Either fix it or drop the batching parameter and document that deletion is intentionally one-at-a-time.

4. The DB check deliberately includes soft-deleted rows — this needs a comment before someone "fixes" it — scripts/cleanup-orphaned-r2-audio.mts:145-157

audio_files uses soft delete (status/deleted_at, migration 20250604204230), and the history action soft-deletes the row while hard-deleting the R2 object (app/[lang]/(dashboard)/dashboard/history/actions.ts:40-52). listStorageKeys has no status filter, so soft-deleted rows still protect their keys.

That's the safe direction and I assume it's intentional — but it's invisible in the code, and adding .eq('status', 'active') looks like an obvious optimization. Doing so would instantly make the tool delete the R2 object for every user-deleted audio file older than 45 days. Given this is the single most dangerous edit anyone could make to this file, it deserves an explicit comment on the query plus a line in scripts/README.md stating that soft-deleted rows intentionally count as references. Fix this →

5. Supabase key pagination has no unique tiebreaker — scripts/cleanup-orphaned-r2-audio.mts:145-157

.order('storage_key', { ascending: true }).range(from, to) sorts on a column with no unique constraint and no index (confirmed across apps/web/supabase/migrations/). storage_key genuinely repeats: clone filenames are content-hash derived (clone-voice/route.ts:1159), so identical input from two users yields the same key. With a non-total sort order, offset paging can compose windows inconsistently and skip a distinct key — surfacing a referenced object as an orphan candidate.

The per-object hasStorageKey recheck before deletion means this cannot cause data loss, so it's low severity — but it can inflate inventory counts. Adding .order('id') as a tiebreaker makes the ordering total and fixes it outright.

Related: hasStorageKey does .eq('storage_key', key) against an unindexed column, so each pre-delete check is a sequential scan. Combined with finding #3, a large deletion run will hammer the database. Worth confirming an index exists before the first big run (per CLAUDE.md, that's your call to apply, not mine to migrate).

6. Backup re-hashes the entire local backup serially on every run — scripts/backup-r2-audio.mts:275-333

The verification loop is for ... await, and verifyLocalFile MD5s every existing file with a simple ETag (r2-transfer.mts:345). Downloads get concurrency: 4, but this phase — which on an incremental re-run is nearly all the work — is fully serial. At today's 2.1 GiB / 5,132 objects that's tolerable; it scales linearly with total bucket size, not with what changed. Worth bounding with the same concurrency the downloads use.

7. Smaller items

  • resolveExistingInputPath reads the whole manifest twice (cleanup-orphaned-r2-audio.mts:558-580, then :213). readFile is used purely as an existence probe; for a large manifest that's a wasted full read into memory. access() or stat() does the job.
  • options.maxDownloadBytes ? truthiness (backup-r2-audio.mts:335) treats 0 as "no cap". parseByteSize rejects zero so the CLI is safe, but runBackup is exported and called directly by tests — !== undefined is the safer predicate for an exported entry point.
  • compareR2ObjectsOldestFirst (r2-transfer.mts:179) calls new Date(...).getTime() inside the comparator, so a sort of n objects does ~2·n·log n ISO parses. Storing epoch ms alongside lastModified on R2ObjectMetadata would remove it.
  • effect@4.0.0-rc.111 and effective-progress@0.12.0 in the workspace catalog: (pnpm-workspace.yaml:57-58). The catalog is workspace-wide, so a release candidate and a 0.x are now the pinned versions any package gets by writing "effect": "catalog:". Only @sexyvoice/scripts uses them today. Since these back a progress bar on an ops script, consider pinning them in scripts/package.json directly rather than the shared catalog.

8. Manifest review workflow is stricter than the docs imply — scripts/lib/r2-orphan-audio-cleanup.mts:439-450

scripts/README.md tells the operator to "Review the JSON under scripts/backups/", but validateManifest cross-checks summary against candidates by exact JSON.stringify equality (:440), and validateInventorySummary requires each inventory entry's candidates totals to match exactly while scanned === younger + referenced + candidates (:717-736).

So removing a single candidate from a reviewed manifest requires hand-recomputing summary, then rebalancing that inventory entry's candidates totals into youngerThanCutoff or referencedByDatabase to keep scanned consistent — otherwise the run aborts. In practice the manifest must be used whole. That's a defensible design, but "review it" reads as "trim it". Either recompute the derived structures when candidates are a strict subset, or state in the README that the manifest must be run as generated.


Docs & conventions

docs/plans/ and .agents/notes/implemented/ follow existing conventions; docs-only commits correctly carry [skip deploy]. scripts/.gitignore keeps generated manifests and reports out of git while allowing the new tsconfig.json through. README covers the required env vars and needed R2 permissions for both commands.


Summary: no correctness bug that risks wrong deletion — the layered safety checks hold up. The things I'd want addressed before this ships are #1 (the destructive path is the one thing here with no orchestration test) and #4 (an undocumented safety property that looks like a bug to a future reader). #2 and #3 matter on the first real deletion run.
· branch feat/cleanup-orphaned-r2-audio-script

@argos-ci

argos-ci Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ✅ No changes detected - Aug 23, 2026, 2:49 PM

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f2b922c1e7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/cleanup-orphaned-r2-audio.mts Outdated
@gianpaj
gianpaj deployed to Production August 23, 2026 09:13 — with GitHub Actions Active

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The cleanup tool's reference check misses the Redis URL cache, which pins the exact R2 storage key with no TTL — deleting those objects leaves live cache entries pointing at 404s that the app will keep serving at zero credits. Separately, the single line that decides which objects get deleted has no test behind it, and runAction isn't exported so no test can reach it.

Reviewed changes

  • Two new operator CLIscleanup-orphaned-r2-audio.mts inventories the allowlisted free-audio prefixes and permanently deletes from a reviewed manifest; backup-r2-audio.mts mirrors buckets or exact prefixes to local disk and never deletes.
  • Shared R2 layerlib/r2-client.mts wraps the AWS SDK, lib/r2-transfer.mts holds pagination, size parsing, safe path resolution, checksum verification, and no-overwrite download finalization.
  • Cleanup policy modulelib/r2-orphan-audio-cleanup.mts owns the bucket/prefix allowlist, the 45-day cutoff, manifest validation, and the pre-delete recheck.
  • Helper extraction — five existing .mts scripts now share loadScriptEnv() and createScriptAdminClient(); reset-freeloader-credits.mts correctly preserves its unique persistSession: true.
  • Deps and wiringeffect@4.0.0-rc.111 and effective-progress@0.12.0 pinned in the workspace catalog, plus a @sexyvoice/scripts test target that Turbo does pick up from root pnpm test.

I verified the Effect 4 RC usage against the pinned versions: mode: 'result', Result.isSuccess, .success/.failure, and the AbortSignal in Effect.tryPromise are all correct, and effective-progress forwards its options to Effect.all unchanged so result ordering is preserved. The flag matrix, the prefix allowlist against every write site, and the per-object pre-delete rechecks all hold up.

⚠️ Nothing has exercised the delete path — not a test, and not a real run

This is a scope question only you can answer, so I'm raising it rather than guessing.

Your implementation note records that the production inventory found zero orphan candidates (.agents/notes/implemented/operations/2026-08-22-r2-orphan-audio-cleanup.md:38 — the free prefixes contained only objects younger than 45 days), and the backup note ends with "Do not run a full backup." That's the right call for validation. But it means --delete has never executed against real R2, and as noted inline it has no test coverage either. The careful parts of this design — the manifest validation, the recheck, the backup gate — are exactly the parts that have never been observed working end to end.

That's defensible for a tool that ships dormant. It's worth being explicit about what closes the loop before the first destructive run: a test around runAction, a dry-run mode for --delete that reports what would be deleted without calling deleteObjects, or a first run scoped to a hand-written single-candidate manifest.

ℹ️ Nitpicks

  • backup-r2-audio.mts:461-466 — the progressAll/Ink branch only runs when process.stdout.isTTY is true, which is what every real operator hits. The test at r2-audio-backup.test.mts:617 passes interactive: false, and a dry run never mounts the renderer, so per the verification record this branch has not been executed. A crash here is recoverable, but it is the default interactive path.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread scripts/cleanup-orphaned-r2-audio.mts Outdated
Comment thread scripts/cleanup-orphaned-r2-audio.mts
Comment thread scripts/r2-audio-backup.test.mts
Comment thread scripts/lib/r2-transfer.mts

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The cache-eviction fix is correct and well-scoped. One minor sequencing issue noted inline.

Reviewed changes (incremental, f2b922c..cd0c271)

  • Cache eviction addedevictDeletedAudioCache() in lib/r2-orphan-audio-cleanup.mts deletes the Redis key after R2 confirms deletion, gated on candidate.bucket === mainBucket so the API speech bucket is correctly left alone.
  • Eviction failures surface — a failed redis.del records deletion-failure with a reason that states the R2 object was deleted, which drives the nonzero exit. Reporting a successful delete as a failure is slightly odd on its face, but it's the right call: a stale cache key is the outcome an operator needs to act on.
  • Two new tests — both are exact rather than loose. assert.deepEqual(deletedKeys, [mainCandidate.key]) genuinely pins the bucket gate (removing the if turns it red), and the rejection test pins error propagation.
  • Dependency and docs@upstash/redis moved to the workspace catalog at 1.37.0 with no version change for apps/web, and AGENTS.md, docs/devops.md, scripts/README.md, and the plan's env-var list all record the new requirement and the KV_REST_API_* variables.

I re-checked the cache-key equality that this fix depends on: clone-voice/route.ts:1133-1134 uploads to R2 and caches under the same filename, so candidate.key is the correct eviction key for cloned-audio-free/ as well as generated-audio-free/.

The other findings from my previous review are unaddressed and their threads remain open, so this is a comment review rather than an approval.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread scripts/cleanup-orphaned-r2-audio.mts Outdated
@gianpaj
gianpaj deployed to Production August 23, 2026 09:27 — with GitHub Actions Active
@gianpaj

gianpaj commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Zed - GPT-5.6 Sol

Addressed the actionable review findings in separate commits:

  • 39e76f1 makes the cleanup entry point import-safe and adds destructive orchestration tests with injected R2, database, cache, clock, log, and report dependencies
  • 0407ca9 documents why soft-deleted audio_files rows remain references
  • af397ae adds id as the stable storage-key pagination tiebreaker
  • 77619d9 rejects a zero cap from direct runBackup callers
  • 32b0ab2 replaces duplicate manifest reads with access checks
  • afc94b2 adds a per-candidate destructive error boundary so reports retain earlier confirmed deletions
  • b2e3588 clarifies that reviewed manifests must remain unedited
  • 5f54f72 documents intentional one-at-a-time deletion

I kept deletion serial because batching would widen the race between the final database check and R2 deletion. I also kept Effect and effective-progress in the workspace catalog because the approved design explicitly requires catalog pins. Serial local hashing and timestamp parsing remain performance follow-ups; the measured production scope is 2.1 GiB, and neither affects correctness. A report-directory write failure cannot be recovered by another write to the same unavailable destination, but recoverable mid-delete errors are now recorded and covered by tests.

Validation after these fixes: 45 scripts tests pass, pnpm fixall passes with the five existing Sentry warnings, and pnpm type-check passes. The local full suite still has the existing closed-Redis failure in the 35 Stripe webhook tests; 62 other web files pass.

@gianpaj
gianpaj deployed to Production August 23, 2026 09:36 — with GitHub Actions Active

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The three prior findings these commits target are genuinely addressed. One gap on the newly-exported runAction noted inline, plus two corrections to my own earlier review.

Reviewed changes — nine commits since the last pullfrog review (cd0c271..48ee744), touching the cleanup command, both test files, the backup cap, and docs.

  • Made the cleanup entry point import-saferunAction is exported and takes injected R2, database, cache, clock, log, and report dependencies, returning {exitCode, report, reportPath} instead of mutating process.exitCode. loadScriptEnv() moved into main(), which now sits behind an import.meta.url entry-point guard matching the pattern backup-r2-audio.mts already used.
  • Added three orchestration tests over the destructive pathassert.deepEqual(events, ['list','head','has','head','delete','cache','report']) is exact and load-bearing: I confirmed by mutation that removing the evictDeletedAudioCache call, or moving the hasStorageKey check after the R2 recheck, each turn it red.
  • Gave each destructive candidate an outer error boundary — an unexpected throw now records deletion-failure for that candidate and continues, so earlier confirmed deletions survive in the report instead of dying with the run.
  • Moved Redis client construction into main() — missing KV_REST_API_* now fails in the first second rather than after a multi-gigabyte download.
  • Stabilized database paginationlistStorageKeys orders by storage_key then id. The tiebreak is total, since audio_files.id is the uuid primary key.
  • Enforced the backup cap for direct callersrunBackup branches on maxDownloadBytes === undefined instead of truthiness, so 0 reaches selectByDownloadLimit and throws instead of silently meaning "uncapped". Mutation-confirmed: removing that guard turns the suite red.
  • Stopped double-reading the manifestresolveExistingInputPath uses access instead of readFile.
  • Documented the invariants — soft-deleted rows are references by design, manifests are approval artifacts rather than editable candidate lists, and deletion stays sequential to keep the check-to-delete gap small. I checked scripts/README.md's "validates the candidate list and all derived totals" claim against validateManifest, which does recompute the summary, validate inventory and bucket totals, and reject duplicate candidates.

All 45 scripts tests pass locally in ~1.8s.

ℹ️ Two corrections to my own earlier review

Both are things I got wrong or can now state with evidence, so they belong here rather than buried in a thread.

The :402 mutation claim was overstated. My initial review said that changing const requestedDeletes = options.download ? backedUp : eligible to = eligible would delete unverified objects with all tests green. The second half is true, the first half is not: the in-loop re-run of verifyLocalFile at :414-456 independently refuses anything that isn't verified-checksum, so the property is guarded twice and unverified objects are still not deleted. The new deletes only checksum-backed objects in download mode test does pin the property end to end — it just cannot distinguish which of the two guards is holding it. I've resolved that thread.

The MD5-mismatch branch is still unexercised, and I can now show it. Forcing the comparison in lib/r2-transfer.mts:345-351 to always report a match leaves all 45 tests green. The new test's "opaque" fixture uses a multipart-style ETag, so simpleEtagMd5 returns null and the code short-circuits to the size path before ever reaching the comparison. That thread and the runDownloadEffects ordering thread both stay open.

On the broader point from the first review: --delete still has not run against real R2, but it now has end-to-end coverage of the composition, which was the half of that concern code could close. The rollout question remains yours.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread scripts/cleanup-orphaned-r2-audio.mts

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. All three open findings are closed, and each fix is mutation-confirmed rather than merely present.

Reviewed changes — four commits since the last pullfrog review (48ee744..a7fe596), 102 lines across one implementation note, cleanup-orphaned-r2-audio.mts, and both test files. This delta is pure test-hardening plus a one-line guard; no production behavior changes.

  • Reached the MD5 mismatch branch for the first timer2-audio-backup.test.mts:287-322 writes a 5-byte wrong-content file against a 5-byte simple-ETag object, so the size check at r2-transfer.mts:330 passes and control reaches the comparison at :346. I confirmed by mutation that if (false && actualMd5 !== expectedMd5) now turns exactly that test red. The test also pins getCalls === 0 and that the local file is byte-preserved, so it covers the "don't re-download, don't clobber" half too.
  • Pinned the runDownloadEffects ordering invariant — each of the 7 items now carries its key as a distinct reason, and the descending delay (items.length - index) * 2 forces item 3 to complete first and item 0 last. assert.deepEqual(outcomes.map(reason), items.map(key)) is exact, not a .some(...). Mutation-confirmed: appending .reverse() to the return in backup-r2-audio.mts:470-472 turns it red. The invariant runBackup:390-397 depends on is now held by a test rather than by an RC dependency's implementation detail.
  • Validated options at the exported runAction boundaryvalidateCleanupCliOptions(options) is now the first statement (cleanup-orphaned-r2-audio.mts:239). This closes both halves of the finding: the {delete: true, force: false, yes: false} bypass, and the {manifest, delete: false, download: false} report-losing throw out of the reconciliation .map(), which the --manifest requires --download or --delete rule (lib/r2-orphan-audio-cleanup.mts:202-204) now also blocks since runAction unconditionally requires a manifest. The new test asserts storageReads === 0 and reportWrites === 0, so it pins "fails before any work", not just "fails".
  • Recorded the invariants behind the tests — the implementation note now states why Effect result order is load-bearing, why the checksum tests use equal-size wrong-content fixtures, and why runAction() re-validates. That is the right place for reasoning a future editor would otherwise have to reconstruct from the assertions.

All 47 scripts tests pass locally in ~1.3s, and every prior pullfrog thread on this PR is now resolved.

The one thing these commits could not close remains open and unchanged: --delete still has not executed against real R2, per .agents/notes/implemented/operations/2026-08-22-r2-orphan-audio-cleanup.md:38. The code half of that concern is now as covered as tests can make it, so what is left is purely a rollout decision — I raised it in the first review and am not re-raising it as a blocker here.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using Claude Opus𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The startup guard and the NODE_ENV placement are both correct. Two rough edges inline, plus one question about what closes the loop on the OOM fix.

Reviewed changes — one commit since the last pullfrog review (a7fe596..99e79fd), touching the backup implementation note, scripts/package.json, scripts/backup-r2-audio.mts, and scripts/r2-audio-backup.test.mts.

  • Added an eager download-directory check to runBackupbackup-r2-audio.mts:225-229 creates the destination and checks W_OK/X_OK before listing R2, skipped entirely when --dry-run so dry runs stay non-mutating. It sits after the --download-dir is required throw, and the per-object mkdir in downloadWithoutOverwrite still exists, so this is a fail-fast check rather than a new prerequisite.
  • Pinned NODE_ENV=production on the backup commandscripts/package.json:9, reached by root package.json:10, so both invocations scripts/README.md documents pick it up. I verified this placement is necessary, not stylistic: backup-r2-audio.mts:7 statically imports effective-progress, whose dist/index.mjs statically imports ink, so assigning process.env.NODE_ENV inside main() would run after react-reconciler has already picked its build. Installed versions confirm the mechanism — react-reconciler@0.33.0/index.js:3 gates on process.env.NODE_ENV === 'production' at require time.
  • Added three tests around the new guard — two are load-bearing. Mutation-confirmed: deleting the block turns creates the download directory before listing and rejects an unusable download directory before listing red, and flipping if (!options.dryRun) to if (true) turns does not create the download directory during a dry run red.

I also checked the blast radius of the env var beyond React. Every package in this script's graph is clean: effect@4.0.0-rc.111, @aws-sdk/client-s3@3.1011.0 with all its @smithy/* transport, retry, and checksum middleware, dotenv@16.6.1, and tsx contain zero process.env.NODE_ENV references, so env loading, signing, retries, checksum validation, and stack traces are untouched. Ink routes render errors through its own ErrorBoundary.onErrorunmount(error) rather than React's console.error defaults, so the production reconciler does not silently swallow renderer failures either.

All 50 scripts tests pass locally, matching the note's updated count.

ℹ️ The OOM fix targets the one path that has never been observed working

The interactive Ink branch at backup-r2-audio.mts:391 is still the only path with no test coverage and no recorded successful run — every test passes interactive: false, and the recorded production dry run never mounts the renderer. The note now describes a concrete failure there (dev reconciler retaining performance measurements until the V8 heap is exhausted), which reads like something you actually hit rather than something you anticipated.

If so, that is genuinely useful: it means the interactive path has now been exercised. What the verification record does not say is whether it was re-run under NODE_ENV=production and observed to complete. .agents/notes/implemented/operations/2026-08-23-r2-audio-backup.md:48-53 lists tests, type-check, Biome, and the earlier dry run, but no interactive run under the new env var. Worth recording one way or the other, since the fix is currently supported by a mechanism argument rather than an observation.

Technical details
# Interactive backup path under NODE_ENV=production is unverified

## Affected sites
- `scripts/backup-r2-audio.mts:391``dependencies.interactive ?? process.stdout.isTTY === true` selects `progressAll` (Ink) over `Effect.all`. No test reaches the Ink branch.
- `.agents/notes/implemented/operations/2026-08-23-r2-audio-backup.md:48-53` — verification record has no entry for an interactive run under the new env var.

## Required outcome
- The record should state whether the dev-reconciler OOM was observed in a real run, and whether an interactive run under `NODE_ENV=production` was then observed to complete.

## Suggested approach (optional)
- A short bounded interactive run (`--max-download-size` set small, against a prefix already fully backed up so nothing transfers) would exercise the renderer without a large transfer, and is consistent with the note's standing "Do not run a full backup."

## Open questions for the human
- Was the heap exhaustion observed, or inferred from Ink/React's dev-build behavior?

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread scripts/backup-r2-audio.mts Outdated
Comment thread scripts/r2-audio-backup.test.mts Outdated
@gianpaj
gianpaj deployed to Production August 23, 2026 14:45 — with GitHub Actions Active

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Both prior findings are properly closed, and the W_OK fix is mutation-confirmed rather than merely present. But this delta hardened the destination check on the command that never deletes, and the same check is still absent from the one that does.

Reviewed changes — two commits since the last pullfrog review (99e79fd..8493838), touching the backup implementation note, scripts/backup-r2-audio.mts (-1 line), and scripts/r2-audio-backup.test.mts.

  • Required the backup destination root to pre-exist — dropping mkdir(options.downloadDir, { recursive: true }) from runBackup leaves only the two access checks, so an unmounted removable volume now fails with ENOENT instead of being shadowed by a fresh directory on the internal disk. I confirmed the removal breaks nothing else: mkdir is still used at :730 for the report directory, whose default (scripts/backups) is independent of --download-dir, and downloadWithoutOverwrite still creates per-object subdirectories, so only the root is a prerequisite.
  • Replaced the mkdir-failure fixture with a real permission testrejects a read-only download directory before listing chmods a temp directory to 0o555, asserts /EACCES/ and listed === false, restores 0o755 in a finally, and skips under root. This is the test the previous review asked for, and it works: mutation-confirmed that deleting access(options.downloadDir, constants.W_OK) at backup-r2-audio.mts:226 turns exactly that test red. Deleting both access lines turns two tests red, where before it left the whole suite green.
  • Recorded the rationale in the note2026-08-23-r2-audio-backup.md:43 now states the unmounted-volume reason rather than just the mechanic, which is the part a future editor would otherwise delete as a redundant check.

All 50 scripts tests pass locally in ~1.7s.

⚠️ The guard landed on the command that cannot lose data, not the one that can

This delta establishes that an unvalidated --download-dir is a hazard worth failing on. That reasoning applies with more force to cleanup-orphaned-r2-audio.mts, where the local copy is not the point of the run — it is the precondition for permanent R2 deletion — and where scripts/README.md:66 documents the same /Volumes/ExternalHD/sexyvoice-r2-bucket destination. I traced the delete path and had it independently re-traced; details are inline on runAction.

Worth being explicit about the scope question underneath it, since it is yours to answer: --download-dir is currently a "write here" instruction for both commands, but for cleanup it is really an assertion that a specific prepared volume is present. Those want different handling — the second argues for something stronger than an existence check, such as requiring a marker file the operator places on the drive once, or recording the resolved destination in the manifest at inventory time and refusing to delete if the action run resolves somewhere else. An existence check is the cheap version and closes the accidental-typo case; the marker-file version closes the wrong-volume case too.

ℹ️ Nitpicks

  • backup-r2-audio.mts:227access(downloadDir, X_OK) is still unexercised. Mutation-confirmed: deleting that one line leaves all 50 tests green, because a 0o555 directory is still traversable. A 0o666 fixture would reach it, or collapsing both calls into access(downloadDir, constants.W_OK | constants.X_OK) would shrink the untested surface to zero. Not worth a test on its own if you prefer the collapse.
  • scripts/README.md:10-13 — the backup destination must now already exist, but the README still shows --download-dir /Volumes/ExternalHD/sexyvoice-r2-bucket with no note, so a first run against a fresh path fails with a bare ENOENT that reads like a bug. The implementation note records the new requirement; the operator-facing doc does not.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

);
const locations = buildAllowedLocations(config);

if (options.download) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runAction never checks that options.downloadDir exists or is writable — validateCleanupCliOptions only checks flag combinations, and resolveLocalObjectPath is pure path math with no filesystem access. So a mistyped or not-yet-mounted destination is created on the fly by downloadWithoutOverwrite's recursive mkdir (lib/r2-transfer.mts:389), the candidates verify as verified-download, enter backedUp, pass the in-loop verifyLocalFile re-check, and reach deleteCandidatesInBatches at :503.

The net effect is that the --download gate reports a verified backup while the only copy sits somewhere the operator did not intend, and the R2 objects are then permanently gone. 9522920 added exactly this guard to backup-r2-audio.mts:225-228 — three lines here would mirror it, and this is the command where getting it wrong is irreversible.

Technical details
# The destructive command does not validate its backup destination

## Affected sites
- `scripts/cleanup-orphaned-r2-audio.mts:252-260` — the first use of `options.downloadDir`, with no preceding existence or permission check.
- `scripts/lib/r2-orphan-audio-cleanup.mts:167-205``validateCleanupCliOptions` enforces that `--download` requires `--download-dir`, but never touches the filesystem.
- `scripts/lib/r2-transfer.mts:278-311``resolveLocalObjectPath` performs path-traversal safety checks only; it does not require the root to exist.
- `scripts/lib/r2-transfer.mts:389``mkdir(path.dirname(destination), { recursive: true })` creates the entire tree including the destination root.
- `scripts/cleanup-orphaned-r2-audio.mts:404-405` and the in-loop `verifyLocalFile` re-check — both compare size/MD5 at a resolved path and have no notion of which volume that path lands on, so neither constrains *where* the backup lives.
- `scripts/README.md:60-68` and `docs/plans/2026-08-22-r2-orphan-audio-cleanup.md:78,121` — both document `/Volumes/ExternalHD/sexyvoice-r2-bucket` for cleanup with no pre-existence caveat.

## Required outcome
- A cleanup run that would download to a destination root the operator has not prepared should fail before any R2 deletion, rather than creating the root and proceeding.

## Suggested approach (optional)
- Mirror `backup-r2-audio.mts:225-228` inside `runAction`, after `validateCleanupCliOptions(options)` and gated on `options.download`, so it fails before the manifest read and before any listing.
- A test in the existing `describe('cleanup action orchestration')` block asserting `storageReads === 0` and `deletes === 0` for a non-existent `downloadDir` would pin "fails before any work", matching the pattern the `validates destructive options for direct callers before work` test already uses.
- Note the honest limit of an existence check: it catches a typo or an unprepared path, but not a volume mounted at the expected path with the wrong contents. If the stronger property matters, requiring an operator-placed marker file on the drive, or pinning the resolved destination into the manifest at inventory time, would cover the wrong-volume case too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant