Skip to content

feat(cleaner): opt-in log of deleted file paths (#247) - #263

Merged
dbfx merged 6 commits into
mainfrom
feat/deletion-log
Jul 27, 2026
Merged

feat(cleaner): opt-in log of deleted file paths (#247)#263
dbfx merged 6 commits into
mainfrom
feat/deletion-log

Conversation

@dbfx

@dbfx dbfx commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Closes #247.

The gap

Scan History records per-category counts and bytes, nothing else. The paths are in hand inside cleanItems and thrown away — kept only for failures, and even those get flattened to errorCount before they reach history.json. So "which files did that 16 GB clean actually remove?" has no answer, which is exactly the question you want answered when an app starts misbehaving after a cleanup.

What this adds

A Cleaning Preferences toggle, "Keep a log of deleted files", off by default. When on, every removed path is appended to deleted-files.jsonl in the Kudu data folder, and the Scan History detail popup grows a paged list of the paths behind that run, with CSV export and reveal-in-folder.

Off by default because the log is a plaintext index of file paths — not something to start writing without being asked. Nothing changes for anyone who doesn't turn it on.

Design notes

  • One hook, every caller. cleanItems is the single chokepoint for file deletion, so manual, scheduled, CLI and cloud-agent cleans are all covered without touching the eight per-category IPC handlers.
  • Buffered writes. Records flush every 500 entries: a six-figure clean neither balloons memory nor becomes a write per file, and a crash mid-clean still leaves everything deleted up to that point on disk.
  • JSONL, rotating at 8 MB. Appends stay O(1) and a truncated tail costs one line instead of the whole file. Corrupt lines are skipped, not fatal.
  • History entries store a time window, not paths. One "System Clean" fans out across eight IPC calls; cleanedFrom/cleanedTo keeps the record-side change confined to cleanItems and keeps six figures of paths off the IPC boundary. Pre-existing entries have no window and simply show nothing.
  • Clearing Scan History clears the log too — leaving it behind would make "Clear" a half-truth.

Also in here (separate commit)

fix(settings): persist cleaning preferences and registry backup mode

While wiring the new toggle I found validateSettingsPartial rejects the entire payload on any unknown key, and neither cleaner.protectRecycleBin nor top-level backupMode was on its allowlist. Since SettingsPage spreads the whole cleaner object on every toggle, every control in Cleaning Preferences and the registry backup-mode dropdown currently fails to persist — the renderer updates optimistically, so it looks saved until the next restart. The new toggle would have hit the same wall. Fixed with both keys allowlisted and a test covering the exact payload shape the UI sends, so the next added key can't silently break the section again.

Scope

File cleaners only. Registry, debloater, driver and privacy actions raise the same question but have different item shapes — worth a follow-up, called out rather than half-delivered.

Testing

  • npm test — 2300 passed / 99 files.
  • New: 14 tests for the store (windowing, paging, rotation across both files, corrupt lines, unwritable data dir), 6 for the cleanItems hook (off by default, batch flush boundary at 500/1000/1200, subcategory fallback), plus validator and channel coverage.
  • npm run build clean. tsc --noEmit shows no new errors — the two that remain on tsconfig.web.json reproduce identically on main.

Follow-up needed

New UI strings were added to en only. npm run translate needs an API key I don't have, so the other 30 locales fall back to English until a maintainer runs it.

dbfx added 2 commits July 27, 2026 05:50
validateSettingsPartial rejects the entire payload when it sees a key
that isn't on its allowlist, and neither cleaner.protectRecycleBin nor
the top-level backupMode was listed. SettingsPage spreads the whole
cleaner object on every toggle, so each control in Cleaning Preferences
— and the registry backup-mode dropdown — failed validation and never
reached disk. The renderer updates its own store optimistically, so the
UI looked correct until the next restart reloaded the old value.

Add both keys to the allowlists with type/value checks, and cover the
exact payload shape the settings UI sends so a future key can't
regress the whole section again.
Scan History recorded only per-category counts and bytes, so there was
no way to answer "which files did that clean actually remove?" — the
paths were in hand inside cleanItems and thrown away, kept only for
failures. That makes it impossible to check whether a cleanup is what
broke an application afterwards.

Add a Cleaning Preferences toggle, "Keep a log of deleted files", that
records every removed path to deleted-files.jsonl in the Kudu data
folder. Off by default: the log is a plaintext index of file paths, and
that is not something to start writing without being asked.

Implementation notes:

- cleanItems is the single chokepoint for all file deletion, so one hook
  covers manual, scheduled, CLI and cloud-agent cleans alike. Records
  are buffered and flushed every 500 entries, so a six-figure clean
  neither balloons memory nor turns into a write per file, and a crash
  mid-clean still leaves everything deleted up to that point on disk.
- JSONL rather than JSON, rotating at 8 MB, so appends stay O(1) and a
  truncated tail costs one line instead of the whole file.
- History entries carry the clean phase's start/end timestamps rather
  than the paths themselves. One "System Clean" fans out across eight
  separate IPC calls, so a time window keeps the record-side change to
  cleanItems alone and keeps six figures of paths off the IPC boundary.
- The History detail popup gains a lazily-loaded, paged list of the
  paths in that window, with CSV export and a reveal-in-folder action.
  Entries from before this change simply have no window and show
  nothing.
- Clearing Scan History clears the deletion log too — leaving it behind
  would make "Clear" a half-truth.

Registry, debloater, driver and privacy actions raise the same question
but have different item shapes; this covers file cleaners only.

@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: af7440ff16

ℹ️ 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 on lines +243 to +244
cleanedFrom: new Date(cleanStartRef.current).toISOString(),
cleanedTo: new Date().toISOString(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Attribute deletion records to their clean run

When a cloud-agent cleanup overlaps a manual cleanup, src/main/services/cloud-agent.ts:2334 calls the same cleanItems function and appends indistinguishable records, while this history entry later selects records solely by the inclusive wall-clock bounds stored here. Its detail list and CSV can therefore attribute remotely deleted paths to the manual run; persist a per-run identifier on each record and query by that identifier instead.

Useful? React with 👍 / 👎.

// Opt-in audit trail of what was removed (issue #247). Buffered so a clean of
// 100k files doesn't turn into 100k appends, and flushed as we go so a crash
// mid-clean still leaves a record of everything deleted up to that point.
const logDeletions = getSettings().cleaner.keepDeletionLog === true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include Windows Recycle Bin deletions in the log

On Windows, when Recycle Bin protection is disabled and that Cleaner category is selected, src/main/ipc/recycle-bin.ipc.ts:93-109 empties the bin through SHEmptyRecycleBin without calling cleanItems. Because logging is enabled only in this cleanItems hook, the resulting history entry reports the cleanup but shows no deleted paths even with the new setting enabled; capture the bin items before emptying it and record the successful deletions.

Useful? React with 👍 / 👎.

Comment thread src/main/services/file-utils.ts Outdated
if (logDeletions) {
pending.push({
ts: new Date().toISOString(),
path: item.path,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record descendants removed by recursive directory deletion

When a cached scan item is a directory, safeDelete removes its complete contents recursively, but this record contains only the directory's path. Consequently the per-file history and CSV omit every descendant file that was actually removed, which prevents the audit trail from answering which file caused a later problem; when logging is enabled, collect and record the descendant paths before recursively deleting the directory.

Useful? React with 👍 / 👎.

Review feedback on #263, all three cases where the log could mislead
rather than merely omit.

Attribute records to the run that made them. A cloud-triggered clean
calls the same cleanItems and its records interleave with a manual run's
by wall-clock time, so History could list remotely deleted paths as part
of a local clean. Records now carry an origin ('local', 'cloud', 'cli')
and History queries its own. Records written before this carry no origin
and read as local, which is where they came from.

Expand directories instead of naming them. A cached scan item is often a
whole directory, and rm removes it recursively — so a single record hid
every file that actually went, which is exactly what the audit trail
exists to answer. Descendants are now enumerated before the delete and
recorded on success, capped at 100k per item with the overflow count
kept on the directory's record so a truncated list can't read as a
complete one. Symlinks and junctions are listed but not descended into,
matching what rm removes. Descendants carry size 0 — the bytes stay on
the directory record, so a CSV sum doesn't double-count and no per-file
stat is needed.

Cover the Windows Recycle Bin. SHEmptyRecycleBin destroys the bin
wholesale without going through cleanItems, so the one clean that
permanently destroys recoverable files logged nothing at all. Its
contents are now enumerated by original location before emptying, and
on a partial empty the survivors are re-read and diffed rather than
guessed at, so the log never names a file that is still there.
@dbfx

dbfx commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all three were real, all three are fixed in 0aa8f34. They shared a theme worth naming: each was a case where the log could mislead rather than merely omit, which for an audit trail is the failure that matters.

1. Attribution across overlapping runs. Correct — cloud-agent.ts calls the same cleanItems with no coordination with the renderer, so its records interleave with a manual run's by wall-clock time. Records now carry an origin (local / cloud / cli) and History queries its own. I kept the time window rather than a per-run id, since a run id still has to reach eight separate IPC handlers to be useful, while an origin tag is one optional parameter and closes the only concurrency that actually exists (local runs can't overlap each other — the UI blocks it and the scheduler waits for idle). Records without an origin read as local, which is where they came from.

2. Recursive directory deletion. The sharpest of the three: a cached scan item is frequently a whole directory, so the log named the folder and hid every file that actually went — precisely the question it exists to answer. Descendants are now enumerated before the delete and recorded on success, capped at 100k per item with the overflow count kept on the directory's record so a truncated list can't read as a complete one (the UI badges it). Symlinks and junctions are listed but not descended into, matching what rm -r actually removes. Descendants carry size: 0 — the bytes stay on the directory record so a CSV sum doesn't double-count, and it avoids a stat per file doubling the I/O of an opted-in clean.

3. Windows Recycle Bin. Also correct, and the worst omission of the three, since emptying the bin is the one clean that destroys recoverable files. Contents are now enumerated by original location before SHEmptyRecycleBin and recorded after. On a partial empty I re-read the bin and diff against the survivors instead of assuming which items went — guessing would have put a filename in the log for a file still sitting in the bin, trading one lie for another.

All three are enumerated only when logging is enabled, so the default-off path costs nothing.

Coverage: 2314 tests / 100 files, up 14 — origin filtering and legacy-record fallback, recursive expansion (nesting, size attribution, no-expansion-when-off, nothing-logged-on-failed-delete), and a new recycle-bin.deletion-log.test.ts covering original-location joins, the missing-location fallback, the partial-empty diff, unparseable enumeration output, and a failed empty.

Also: the translation bot picked up the en strings on this branch, so the locale follow-up I flagged is already done — bar two keys added in this commit.

@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: 0aa8f3464e

ℹ️ 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 on lines +127 to +130
async function listDescendantFiles(dirPath: string): Promise<{ paths: string[]; truncated: number } | null> {
let rootEntries: Dirent[]
try {
rootEntries = await readdir(dirPath, { withFileTypes: true })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid logging descendants reached through a root symlink

When a scanned item is itself a symlink to a directory, scanDirectory includes it because stat follows the link, and this initial readdir(dirPath) follows it again. The descendant list therefore contains the target directory's files, but rm(..., { recursive: true }) removes only the symlink; with logging enabled, History and CSV falsely report all target files as deleted. Check the root with lstat and skip expansion when it is a symbolic link.

Useful? React with 👍 / 👎.

Comment on lines 197 to +204
const result = await safeDelete(item.path)
if (result.success) {
totalCleaned += item.size
filesDeleted++
if (logDeletions) {
const ts = new Date().toISOString()
const category = item.subcategory || item.category
const record: DeletedFileRecord = { ts, path: item.path, size: item.size, category, origin }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not log missing cached paths as deleted

When a cached file disappears after scanning but before cleaning—a common race for temporary files—rm with force: true reports success, so this branch writes a deletion record even though Kudu removed nothing. This makes the new audit trail incorrectly attribute an external deletion to the clean; establish that the path existed immediately before deletion or distinguish the missing-path result before appending the record.

Useful? React with 👍 / 👎.

Comment thread src/main/cli.ts
let recycleCleaned: CleanResult = { totalCleaned: 0, filesDeleted: 0, filesSkipped: 0, errors: [], needsElevation: false }
let dbCleaned: CleanResult = { totalCleaned: 0, filesDeleted: 0, filesSkipped: 0, errors: [], needsElevation: false }
if (fileItemIds.length > 0) fileCleaned = await cleanItems(fileItemIds)
if (fileItemIds.length > 0) fileCleaned = await cleanItems(fileItemIds, undefined, 'cli')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Log Windows CLI recycle-bin deletions

When kudu --cli clean includes the Recycle Bin on Windows and deletion logging is enabled, this changed flow tags ordinary file deletions as cli but still calls the separate cleanRecycleBin helper at line 1539; that helper deletes through PowerShell without enumerating or calling recordDeletions. Thus the CLI clean reports success while every Recycle Bin path is absent from the log. The fresh evidence beyond the earlier IPC comment is this independent CLI-only deletion implementation.

Useful? React with 👍 / 👎.

Second review pass on #263. Same failure class as before: cases where
the log states something false rather than merely omitting it.

Don't expand a symlinked directory. scanDirectory stats through links,
so a symlink to a directory arrives as an ordinary directory item, and
readdir would then list the target's contents — while rm(recursive)
only unlinks the link. Every file in the target would have been logged
as deleted while still sitting on disk. The root is now checked with
lstat, which reports the link itself, so only the link gets recorded.

Don't log paths that were already gone. rm(force) reports success for a
path that no longer exists, so a temp file that disappeared between the
scan and the clean — an ordinary race for the directories Kudu cleans —
was recorded as something Kudu removed, attributing a third-party
deletion to us. The same lstat answers this: no entry, no record. The
existing counters keep their current behavior; only the audit trail is
held to what we actually removed.

Cover the CLI's recycle bin too. `kudu --cli clean` empties the bin
through its own PowerShell helper rather than the IPC handler fixed
last time, so Windows CLI cleans still logged nothing. The enumerate,
diff and record logic moves to services/recycle-bin-log.ts and both
callers use it, tagged 'local' and 'cli' respectively — one
implementation instead of a third copy to keep in sync.
@dbfx

dbfx commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Round two — all three valid again, fixed in a252b80. Same failure class as last time: the log stating something false rather than merely omitting it.

Root symlink expansion. Good catch, and the nastiest of the set. scanDirectory stats through links, so a symlink to a directory arrives as an ordinary directory item; readdir then followed it a second time while rm(recursive) only unlinks the link — so every file in the target would have been logged as deleted while still sitting on disk. Now gated on lstat, which reports the link itself, so only the link is recorded.

Already-missing paths. Also right, and it's the more common one in practice given what Kudu cleans — temp directories churn constantly between scan and clean. rm(force) reports success for a path that was never there, so the log credited Kudu with someone else's deletion. The same lstat answers it: no entry, no record. One call now covers both fixes. I deliberately left filesDeleted/totalCleaned alone — that counting predates this PR and changing it is a separate behavior change — so only the audit trail is held to what we actually removed.

CLI recycle bin. Correct that it's a distinct implementation from the IPC handler fixed last round; cleanRecycleBin in cli.ts empties via its own PowerShell and logged nothing on Windows. Rather than add a third copy of enumerate/diff/record, that logic moved to services/recycle-bin-log.ts and both callers use it, tagged local and cli. The re-read-and-diff optimization I had inline (skip the survivor check when the count came back zero) went away in the extraction — always diffing is one COM enumeration of an empty bin, which isn't worth a branch that can only ever be wrong.

2326 tests / 101 files, up 12: a service-level recycle-bin-log.test.ts (original-location joins, bare-name fallback, ConvertTo-Json's lone-object case, malformed output, the survivor diff, origin pass-through) plus cleanItems cases for the vanished path and the symlinked directory. The symlink test skips itself on unprivileged Windows without Developer Mode rather than failing there.

One test went the other way: records exactly the files that were actually deleted asserted recorded.length === filesDeleted, which encoded precisely the missing-path behavior you flagged. Deleted rather than adjusted — the new test states the corrected invariant directly.

Translations for the two keys added last round landed via the bot in 39531ec, so all 30 locales are current.

@@ -1,10 +1,12 @@
import { rm, stat, readdir, open, writeFile } from 'fs/promises'
import { rm, stat, lstat, readdir, open, writeFile } from 'fs/promises'

@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: a252b80bc0

ℹ️ 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 on lines +81 to +82
const survivors = new Set((await listRecycleBinContents()).map((entry) => entry.path))
const emptied = before.filter((entry) => !survivors.has(entry.path))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat a failed recycle-bin re-read as unknown

When the post-clean PowerShell enumeration fails in either Windows IPC or CLI flow, listRecycleBinContents() catches the error and returns the same empty array used for a genuinely empty bin. This line consequently treats every pre-clean entry as deleted, so a transient COM/PowerShell failure—especially after a partial empty—creates false deletion records for files still in the Recycle Bin; propagate a distinguishable failure result and skip recording when survivor state is unknown.

Useful? React with 👍 / 👎.

Comment on lines +144 to +146
if (paths.length >= MAX_LOGGED_DESCENDANTS) {
truncated++
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count descendants omitted after reaching the log cap

When the 100,000-path cap is reached and a subsequent entry is a directory, this branch increments truncated only once and skips descending into that directory. For example, an omitted directory containing 10,000 files produces a +1 more badge even though 10,001 paths were removed, making the audit trail materially understate its truncation; either count the skipped subtree or represent truncation without an exact count.

Useful? React with 👍 / 👎.

@dbfx
dbfx merged commit 7231707 into main Jul 27, 2026
10 checks passed
@dbfx
dbfx deleted the feat/deletion-log branch July 27, 2026 04:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request / Question: Is there a way to view a log or history of deleted files?

1 participant