Repository navigation
#52: should the daemon route through the shared pass, and does it log? #95
Description
Question
AutoOrganizeService.runOrganization (src/extensions/scheduler/auto-organize.service.ts:258) moves files and writes no history entry at all. There is no ctx.history.log(...) call anywhere in that file. A daemon-organized pass is invisible: it moves files, leaves nothing in operations.jsonl, writes no rollback manifest, and cannot be undone.
runOrganizePass() does write a row, but with source: "manual" and no manifestId. The source ticket fixes the label. This ticket asks the harder question: should the daemon route through runOrganizePass() at all, or keep its own private pass and just start logging?
The trade, stated plainly.
AutoOrganizeService.runOrganization is a second implementation of a pass, and it has three features runOrganizePass lacks:
min_file_age_minutesfilter (auto-organize.service.ts:286)max_files_per_runslice (auto-organize.service.ts:299)- a per-directory reentrancy guard,
runningDirectoriesSet (auto-organize.service.ts:265)
It also hardcodes dryRun: false (:317) and calls stateService.setLastRunTime(...) (:338), which runOrganizePass never touches.
Option A: the daemon calls runOrganizePass() per tick, and those three features move into the shared pass. This is what #52 asks for and what the file header at organize-pass.ts:8 already plans. One implementation, one history write path, the daemon's passes become undoable for free.
Option B: keep the daemon's private pass, just add history.log() to it. Smaller diff, no feature migration. Leaves two implementations of a pass forever, and #52 stays unsolved.
Constraints that bind the answer.
runOrganizePassconstructs its ownFileScannerServiceandOrganizerServicewith no injection seam (organize-pass.ts:77). The existing tests intests/unit/services/auto-organize.test.tsuse constructor injection onAutoOrganizeService(:38). Option A means either adding a seam to the pass or rewriting those tests to assert on disk state in a sandbox.WatchConfig.rulesalready carriesmin_file_age_minutes,max_files_per_run,catchup_mode(src/core/config/loader.ts:28). Moving them into the pass options means they stop being daemon-only.dryRun: falsehardcoded atauto-organize.service.ts:317cannot move into the shared pass as a default, since the pass defaultsdryRuntotrue(organize-pass.ts:69) and that default is deliberate.- Catchup logic (
shouldRunOnStartup,auto-organize.service.ts:464) readsscheduler-state.jsonviashouldCatchup. The pass has no state service. Catchup is daemon startup behaviour, not pass behaviour, so it stays in the daemon even under Option A.
What to decide. A or B. If A, say whether the three features become pass options (recommended, they are per-directory tuning that belongs with the directory) or stay daemon-side as a pre-filter over the file list.
Note. #52 is this ticket. The implementation is a follow-up child of this one, created once A or B is chosen, because the two answers produce very different diffs. If B, the thin-loop framing of #52 does not apply at all and the issue's premise is wrong.
Blocks. #52, #31 watch_status (a daemon pass's last-run time is only observable if it records one).
Activity
kridaydave commentedon Oct 11, 2026
Decision
Option A. The daemon routes through
runOrganizePass(). The private pass body inAutoOrganizeService.runOrganizationis deleted. One pass implementation, one history write path, and daemon passes become undoable for free.Two facts the ticket got wrong
The daemon already writes a rollback manifest. This ticket says it does not. It does:
organizer.organize()creates the manifest itself atsrc/core/organize/organizer.ts:648, andrunOrganizationcalls that. The manifest exists on disk. Nobody knows its id.Both paths discard the manifest id.
src/extensions/scheduler/organize-pass.ts:93destructures the organizer result and never readsmanifestId. Thehistory.log()call at:120does not pass it either. So every--once --applyrun leaves an undo manifest on disk that nothing can name.That is the real gap, and it is why #102 exists: orphan manifests at 96 a day with no recorded id. Fixing it here makes #42 and #102 honest rather than theoretical.
The six settled answers
Option A.
runOrganizationkeeps its cron scheduling and its logging. The scan, filter, and organize logic goes away.Record the manifest id in this ticket. Three lines, and it unblocks undo_history selective undo #42.
Age and size limits are daemon-supplied pass options. New fields on
OrganizePassOptions. Defaults live in the daemon:min_file_age_minutes: 60,max_files_per_run: 300.--oncegets neither, so an automated tool never churns an entire filesystem in one tick.Worth stating why: if the defaults applied to every pass, a file dropped in Downloads would sit untouched for an hour. For a cron sweep that is the point. For a person running
--once --applyby hand it is a surprise.scheduler-state.jsonsurvives, daemon-only. The daemon callssetLastRunTimeafter the pass returns, as it does today. The pass stays pure and never touches scheduler state.This settles a fog item on the wayfinder map. A cron setup that calls
--oncedirectly writes no scheduler state and never will. That is fine: catchup is daemon startup behaviour, and a machine with no daemon has no startup.Concurrency allowed across directories, one daemon process.
runningDirectoriesis deleted. node-cron already fires different directories independently, so nothing is lost by removing the guard.The failure mode without it: two passes over the same directory plan the same file, one wins, the loser gets a source-missing error. A normal error, already handled by the organizer's error path. Not corruption.
Dry run stays a pass option with the default on. The daemon passes
dryRun: falseexplicitly at its call site.Not negotiable, and worth being blunt about the alternative.
organize-pass.ts:26defaults dry run to true so a bare--onceplans and stops. Flipping that default means bare--oncemoves real files, which contradictsONCE_USAGEatonce-cli.ts:100and the frozen exit contract. One fat finger in a crontab line and someone's home directory reorganizes without them asking.Implementation
src/extensions/scheduler/organize-pass.tsmin_file_age_minutes?: number,max_files_per_run?: number.filterByAgehere fromauto-organize.service.ts:363. It is a pure function over a file list, it belongs with the pass.max_files_per_runslice after the age filter, matching the current order atauto-organize.service.ts:286then:299.manifestIdoff the organizer result and pass it tohistory.log().src/extensions/scheduler/auto-organize.service.tsrunOrganizationbecomes: validate, callrunOrganizePasswith{ directory, dryRun: false, source: "scheduled", min_file_age_minutes: rules.min_file_age_minutes ?? 60, max_files_per_run: rules.max_files_per_run ?? 300 }, thensetLastRunTime.organizer.organizecall, andrunningDirectories.source: "scheduled". Catchup is still a scheduled pass; it fired because a schedule was missed, not because a person asked.Defaults belong to the daemon, not the pass. The pass options stay optional with no defaults of their own.
--oncecallers get no limit because they pass no value, and that is the whole mechanism. Do not put60inOrganizePassOptionsas a default; it would silently apply to every caller.Tests that must fail without this change
source: "scheduled"and a non-emptymanifestId.manifestIdnames a manifest thatgetManifestverifies, andundo_last_operationwith that id moves the files back.runOrganizePassreturns the manifest id in its result, so a caller can report it.max_files_per_run: 300moves 300 and reportsplanned: 300.min_file_age_minutesis not moved.--oncewith no options still moves every file regardless of age and count.--oncestill writes nothing. Guards thedryRundefault.Proof
Drive
once --apply --source scheduledthrough the verify sandbox, then read the history row back and confirmmanifestIdis set andundo_last_operationaccepts it.Then drive the daemon path with a watch config carrying
max_files_per_runand confirm the same row shape.Not decided here
kridaydave commentedon Oct 11, 2026
Status: half shipped
PR #103 landed one part of this. The other part is still open work.
Done
748bd0f fix(scheduler): a pass that moves files now leaves an undo handle on its row.runOrganizePassnow readsmanifestIdoff the organizer result and records it on the history row (src/extensions/scheduler/organize-pass.ts:128). That was the part that made scheduled passes undoable, and it was the part the ticket got wrong about, so good that it landed first.Still to do
The daemon does not route through the shared pass.
AutoOrganizeService.runOrganizationstill has its own private implementation:runningDirectoriesreentrancy guard still present (auto-organize.service.ts:34,:265,:271)filterByAgestill in the daemon (:286, method at:363)max_files_per_runslice still in the daemon (:298)organizer.organizecall, norunOrganizePassanywhere in the fileSo there are still two pass implementations. The daemon's writes a history row now, but it goes through its own path rather than the shared one.
Settled answers that still need implementing
runOrganizationkeeps its cron scheduling, its logging, and itssetLastRunTime. The scan, the age filter, the size slice, and theorganizer.organizecall go away.runOrganizePasswith{ directory, dryRun: false, source: "scheduled", min_file_age_minutes: rules.min_file_age_minutes ?? 60, max_files_per_run: rules.max_files_per_run ?? 300 }.OrganizePassOptionsfields with no defaults in the pass. The 60 and 300 defaults live in the daemon.--oncepasses nothing and therefore gets no limit, which is the entire mechanism that keeps an automated tool from churning a whole filesystem in one tick.filterByAgeintoorganize-pass.ts. Pure function over a file list, belongs with the pass.runningDirectories. One daemon process, many directories, parallel across directories. node-cron already fires independent schedules.organize-pass.ts:26defaults to true so a bare--onceplans and stops;ONCE_USAGEatonce-cli.ts:100promises it and the exit contract is frozen. Flipping that default means bare--oncemoves real files.scheduler-state.jsonstays daemon-only. The daemon callssetLastRunTimeafter the pass returns, exactly as today. The pass stays pure.One correction
The ticket claimed the daemon writes no rollback manifest. It did, at
src/core/organize/organizer.ts:648, becauseorganizer.organizecreates it internally. What was true is that every path discarded the id. #103 fixed that.kridaydave commentedon Oct 12, 2026
Decided and shipped by #109 (merge
77e64ad). Option A: the daemon routes through the shared pass.AutoOrganizeService.runOrganizationnow callsrunOrganizePass, so a cron tick and--onceproduce the same history row and the same rollback manifest id, and a scheduled pass is undoable. The three daemon-only features moved into the pass options (min_file_age_minutes,max_files_per_run), the reentrancy guard sits on the cron task and coverstriggerNowandrunMissedSchedulestoo,dryRun: falsestays a daemon-side argument because the pass default is deliberatelytrue, and catchup stays daemon-side. The injection seam question did not come up: the pass kept its own construction and the existing tests still hold.