Resolve 26 spec items from 2026-05-17 multi-agent review - #79
Open
curtyo18 wants to merge 29 commits into
Open
Conversation
… constant Three sites had the same 20-field row→FileRecord mapping; one constant was duplicated under two names. Extract toFileRecord from files-repo and import it from both planners. Export QUARANTINE_DIR_NAME from quarantine and use it from organize/applier. Closes #74
- Add idx_files_scan_id (markMissing now uses index, not scan) - CHECK constraints on batches.status, operations.status - ON DELETE CASCADE/SET NULL on operations.batch_id, operations.file_id, quarantine.batch_id - idx_files_state rebuilt as partial index WHERE state != 'indexed' - Spec §4.1 updated for the partial index Closes #71
…uctured logger consistency Sweep of small style/clarity items: named constants for repeated literals (chunk size, cache-control, eviction fraction, scheduler interval), single-letter renames in rules/scan code, dirname() replacing path.slice, structured logger in undo.ts where console.error was used, shared DEFAULT_FILL_THRESHOLD_PERCENT. No behaviour changes. Closes #78
Threads the verified destination hash into updateOperationStatus so the startup reconcile dest-matches-post_hash branch and undo verification can operate against the hash captured at move time rather than the catalog's current files.sha256 row (which may have been re-scanned independently). - moveSameDrive / moveCrossDrive: MoveOutcome now carries postHash (renamed from verifiedHash to match DB column name and remove ambiguity) - organize applier: passes outcome.postHash as postHash - dedupe applier: passes liveHash as postHash - undo: prefers op.postHash for the drift check; error text mentions post_hash so callers can distinguish from legacy "hash drift" - legacy ops with null post_hash fall back to files.sha256 unchanged - added symmetric cross-drive applier test to mirror same-drive test Closes #53
…dest on copy failure Cross-drive copies now follow open → pipeline → handle.sync() → close → re-open for hash, with parent-dir fsync on POSIX. Bytes are durable before the source is renamed into quarantine, delivering spec §12.3's power-loss invariant. Pipeline failures (EIO, ENOSPC, EACCES) now unlink the partial destination before rethrowing, extending closed #20's hash-mismatch cleanup to all copy error paths. DriveError disconnects attempt the unlink and swallow the secondary failure (option 1 from B5's discussion). Closes #54 Closes #55
…mbiguous field
Per-kind post-conditions replace the destination-only classification:
- move: dest matches AND source absent → completed; dest matches AND source
present → failed (anomalous, alert user)
- copy: dest matches → completed regardless of source
- quarantine/restore: per-kind path checks
Decision shape gains explicit ambiguous: boolean so the counter no longer
uses .includes('ambiguous'). hashFile errors during reconcile are warn-logged
with op id; previously they fell through silently and were indistinguishable
from hash mismatch.
Closes #63
…lper inverseUndoKind(op) centralises the 'move' / 'restore' selection used by all three undo paths (success cross-drive, success same-drive, failure). The failure path previously recorded op.kind (original kind, e.g. 'copy') instead of the inverse, under-counting failed undos in any query keyed by operations.kind. batches-repo.ts's RecordOperationInput now accepts optional errorMessage so the failure path records the op-with-error in one DB write instead of a record-then-updateStatus pair. Closes #65
Spec §12.3's startup integrity_check contract was missing — a corrupt
catalog file was opened and used silently. assertCatalogHealthy now runs
after migrate and throws CatalogError('CATALOG_CORRUPT') on any non-'ok'
result. CLI surfaces the error cleanly (named catalog path + rebuild
docs link), exits non-zero.
Closes #56
Pre-flight in scan/orchestrator.ts now reads the live serial of the
drive letter and compares against the catalog's stored volume_serial.
Mismatch throws ScanError('VOLUME_SERIAL_MISMATCH'); API returns 409.
This catches the drive-letter-remapped case where the catalog and the
physical drive disagree, before the scan corrupts state.
The check is skipped for synth- serials (POSIX path-derived) because
synthSerial hashes the full path, not the mount point, so
detectVolume(mountPath) and detectVolume(originalPath) produce
different values even on the same filesystem. Only real OS-issued
serials (Windows volume UniqueId) can be compared against a mountPath
re-detection with reliable semantics.
The API translates the pre-start rejection via Promise.race so the
handler does not hang waiting for onStart, which never fires when
runScan throws before scansRepo.start().
Closes #57
…8.4) Hardlinked copies share physical bytes; quarantining a non-keeper reclaims zero space and confuses the user. detectDuplicates now collapses copies sharing (driveId, ntfsFileId) within each group, drops groups with <2 distinct physical copies, and recomputes reclaimableBytes. samePhysicalFile is surfaced on the duplicates API response. Sub-fix: scan now writes stat.ino as ntfs_file_id; previously the column was always null in production so the hardlink collapse never fired. POSIX inode works as a hardlink-equivalence key here too. Closes #58
Dedup applier read chunk size hardcoded to 1 MB regardless of the user's current throttle profile. ApplyDedupeInput now accepts chunkBytes/sleepMs; API reads them from the shared ThrottleManager and threads them through. Idle profile dedup now reads at 256 KB, full-send at 4 MB, per spec §5.5. Closes #61
…spec §5.5) setInterval was best-effort and drifted under busy event loops; missed all intermediate ticks during sleep/hibernate. Replaced with self-rescheduling setTimeout aligned to the next interval boundary. On wake (long-gap detection), an immediate re-evaluation runs so the active profile matches the schedule within the next tick window after wake. Injectable now() clock removes dependency on vi.useFakeTimers in tests. Closes #62
Six catch-and-swallow sites in metadata-image, metadata-video, and
walker now log at warn level with { path, err } (or kind for stat).
Scan continues unchanged; the only difference is visibility for an
operator investigating "why isn't this file/folder in the catalog?"
Closes #70
runScan was ~165 lines mixing pre-flight, per-file loop, progress, and three terminal branches. Extracted processOneFile (per-file business logic) and finishScan (terminal status writes). runScan body now ≤60 lines. Behaviour unchanged; existing tests pass without modification. Closes #66
Background scan's .catch only logged; onStartFired was never resolved on pre-onStart errors, so the handler's await hung forever and clients timed out with no error surfaced. T09 wired the Promise.race; this commit completes the error mapping so any pre-onStart ScanError or DriveError lands as a structured 4xx/5xx within 100 ms (DRIVE_NOT_FOUND → 404, DRIVE_DISCONNECTED → 503, others → 500). activeScans map stays clean (registeredId null → finally skip works). Closes #60
D) app.onError maps typed errors (Rule/Drive/Quarantine/Integrity/Catalog/
Scan) to structured statuses; unknown errors → 500 { error: 'internal' }
with the original logged. Removed the (err as Error).message leaks at
the three previously scattered sites.
B) parseJsonBody helper. All POST/PUT/PATCH handlers go through it;
malformed JSON → 400 { error: 'invalid-json' }.
C) zod validators for Settings, CreateRoleInput, UpdateRoleInput in
packages/engine/src/api/validators.ts (kept out of shared/ to avoid
widening the dep surface). POST /api/rules wraps rules.create() so
RuleError surfaces as 400 via onError.
A) Number.isFinite guards on /api/files and /api/batches limits.
Closes #68
… help, mediainfo path, shutdown logging - parseArgs splits on first '=' so --catalog=/foo works - --version / -V prints package version, exits 0 - Unknown command prints help on stderr after the error line - mediainfoPath default resolved from import.meta.url (works outside cwd) - shutdown() try/catches each teardown step and logs failures Closes #69
…on test) foreign_key_check was running AFTER tx() committed, so an FK-violating migration left schema_version permanently advanced and silently skipped the bad migration on next boot. Moved the check inside the transaction so a violation rolls back both the migration and the schema_version insert. Replaced the inline-copy test with one that runs migrate() against a fixture migrations dir holding a deliberately-broken migration. Closes #59
quarantine and cleanup guards used case-sensitive string comparison on a case-insensitive NTFS filesystem (README primary target). Centralised in drives/paths.ts. On win32 both sides lowercase before compare; POSIX unchanged. Quarantine guard, empty-dir sweep, and api server's isPathUnderAny all use the same helper now. Closes #64
…ard, tiebreaker, date invariant
- picomatch compiled once per enabled rule at firstMatch call site (was once
per (file,rule) pair); globs hoisted into a compiled[] map before the loop
- file.path backslashes always normalised to '/' before picomatch evaluation
so a single pattern works on both POSIX and Windows paths
- Malformed pathGlob (strictBrackets: true) throws RuleError('INVALID_GLOB')
at precompile time; bubbles to 400 via API onError handler
- Migration 0007 adds created_at to rules; repo ORDER BY now priority ASC,
created_at ASC, name ASC for deterministic equal-priority tiebreaker
- JSDoc'd dateBefore/dateAfter as ISO-8601 lex-compare invariant; pathGlob
documents forward-slash convention and normalisation behaviour
- Boundary test: fileDate === dateBefore → no match; fileDate < dateBefore → match
Closes #67
… injectable clock - SettingsRepo.load() validates the row against zod SettingsSchema; malformed fields fall back to defaults with a warn log - Optimizer stores lastOptimizedAt inside the Settings JSON blob, removing the second writer on the settings table - Optimizer.shouldRun accepts an optional now() clock so tests don't need vi.useFakeTimers for this concern Closes #72
…itest 4 deferral noted
- exifr: pinned, code comment documents the stay-on-exifr decision and
alternatives considered (split-package @exifr/parse, subprocess-heavy
exiftool-vendored, unmaintained node-exif). Last npm release:
2022-05-01T21:24:18.198Z.
- piexifjs: removed in favour of sharp's withMetadata({ exif }) — spike
succeeded; fixture builder now uses sharp's native EXIF write path.
DateTimeOriginal round-trips correctly via exifr.parse({ reviveValues:
false }). piexifjs.d.ts also deleted.
- vitest: comment in vitest.workspace.ts noting defineWorkspace → projects
migration deferred to its own effort. Pinned at ^2.1.0 (resolves to
2.1.9), latest is 4.1.6.
- vite/jsdom: excluded from scope per spec.
Closes #73
…le→rename The MediaInfo binary fetched from mediaarea.net was used without integrity verification — a future origin compromise (or MITM on a proxy that re-signs TLS) would land arbitrary code in packages/engine/bin/mediainfo. Now pins MEDIAINFO_ZIP_SHA256, verifies after download, refuses to install on mismatch with expected/actual printed. Zip downloads as .partial and renames only after verification. SHA-256 captured 2026-05-19 from MediaInfo_CLI_24.06_Windows_x64.zip. Closes #75
…ype-checked ESLint, noImplicitOverride - actions/setup-node@v4 now has cache: 'npm' - Lockfile fallback dropped — missing lockfile fails loudly - node-version pinned to '22.x' - tsconfig: noImplicitOverride: true - ESLint: projectService + no-floating-promises + no-misused-promises. Full recommendedTypeChecked not enabled — no-unsafe-* produces excessive noise on legitimate untyped external libs (exifr, better-sqlite3 raw rows). checksVoidReturn.attributes:false suppresses Preact onClick false positives. Targeted fixes applied: void-operator on 14 floating promises, fix setTimeout(asyncFn) in scans, type-alias two empty interfaces. Closes #76
…ows-skip + hostile-filename test Three test-helpers replace duplicated boilerplate: - silentLogger() replaces writes:string[] = [] shim sites - waitForScanStatus(db, scanId, target) replaces polling loops - drain(asyncIterable) replaces for-await/void patterns Plus: - metadata-video shim test skipped on win32 - walker test collects entries + asserts length (was 'pass with zero assertions' on empty iterable) - template.test.ts adds hostile filename cases (slashes / backslashes) Closes #77
…gaps
Code-review feedback batch:
- Extracted SettingsSchema out of api/validators.ts into catalog/settings-schema.ts;
catalog/settings-repo.ts no longer imports upward
- Removed no-op try/catch wrapper around Promise.race in POST /api/scans
(Hono's onError already handles propagation)
- app.onError fallback now writes structured JSON to stderr instead of
console.error, matching the rest of the engine's log shape
- finishScan emits log.info(`scan-${status}`) instead of if/else,
eliminating a 'failed' status being logged as 'scan-completed'
- Added 4 missing tests: reconcile quarantine_path-missing → failed,
scan writes stat.ino as ntfs_file_id, dateAfter boundary symmetric
to dateBefore (2 cases)
…r extraction - Remove unimported test-helper waitForScanStatus (was speculative) - Remove unread lastTickAt field on ThrottleScheduler (write-only state) - Document why moveSameDrive uses row.sha256 as post_hash (rename atomicity preserves bytes; cross-drive path differs because pipeline copies can corrupt mid-stream) - Narrow parseJsonBody catch to SyntaxError so non-parse exceptions propagate to onError instead of being mis-mapped to invalid-json - Extract makeCapturingLogger to test-helpers; metadata-image.test.ts and metadata-video.test.ts now import the shared helper
SQLite forbids non-constant defaults in ALTER TABLE ADD COLUMN. The
DEFAULT CURRENT_TIMESTAMP form passed on the Linux SQLite shipped by
better-sqlite3 (3.53.0) but failed on the stricter Windows-prebuilt
SQLite ("Cannot add a column with non-constant default"), wedging the
first run on Windows before the server could start.
The migration now uses a constant '' default and backfills real ISO
timestamps in the same statement. RulesRepo.create() supplies created_at
explicitly on every insert, so the placeholder default is never observed
in practice — the column is effectively always populated with a real
timestamp at row creation time.
Adds a regression test asserting RulesRepo.create() lands a non-null
created_at within wall-clock bounds of the call.
…ecovery Double-click launcher for non-terminal users on Windows. First run does npm install, fetch-binaries, build; subsequent runs are fast. The script handles two cross-OS development landmines: * Marker file (.installed-by-start-bat) stamped after a successful setup. If absent on next launch — typically because a WSL build on the shared bind mount repopulated node_modules — the script wipes and reinstalls cleanly for Windows. * Per-package recovery (:ensure_native_modules) catches the lighter cases that don't blow the marker away: better-sqlite3's compiled .node from the wrong OS, and npm bug #4828 leaving the rollup platform-binding optional dep uninstalled.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
move-cross-drive.ts); final commit is a code-review polish bundle.What changed (by domain)
Correctness (B-class blockers)
post_hashnow written on every completed move/copy/dedupe op (B3: [Blocker] post_hash never written — reconcile completed-branch and undo verification both dead #53) — fixes the silent-corruption reconcile pathPRAGMA integrity_check; engine refuses to start on a corrupt catalog (B6: [Blocker] Missing startup PRAGMA integrity_check — spec §12.3 contract not delivered #56)stat.inoasntfs_file_idEngine internals (M-class majors)
app.onErrorcentralised; zod-validated settings/role bodies (M21: [Major] API input hardening — NaN limits, JSON parse, settings/role/rule validation, error shape #68)/api/scansno longer hangs on pre-onStart rejection (M13: [Major] POST /api/scans hangs forever if runScan rejects before onStart fires #60)op.kindwith explicitambiguousfield; hash-read errors warn-logged (M16: [Major] Reconcile decisions ignore op.kind; partial-write hash mismatch silently swallowed (spec §12.3) #63)schema_version(M12: [Major] Migration FK violations leave schema_version committed; test exercises copy of guard (follow-up to M10) #59)inverseUndoKindhelper (M18: [Major] Failed-undo op records original op.kind instead of inverse-undo kind #65);runScanextracted intoprocessOneFile+finishScan(M19: [Major] runScan in scan/orchestrator.ts mixes setup/walk/finalize across ~165 lines #66)RuleError('INVALID_GLOB'), deterministic tiebreaker, date-invariant JSDoc (M20: [Major] Rules matcher hardening — picomatch reuse, Windows paths, glob guard, tiebreaker, date compare #67)--flag=value,--version, unknown-cmd help, mediainfo path, shutdown logging) (M22: [Major] CLI hardening — --flag=value, --version, unknown-command help, mediainfo path, shutdown swallow #69)SettingsRepowith injectable clock (M25: [Major] Settings/optimizer cleanup — schema validation, settings-table sharing, injectable clocks #72)Infrastructure (T-class tooling)
fetch-binariesnow SHA-256 verifies MediaInfo download (.partial → rename), refuses on mismatch (T1: [Security] fetch-binaries.ts has no SHA-256 verification of downloaded MediaInfo binary #75)noImplicitOverride, type-checked ESLint (no-floating-promises+no-misused-promises) (T2: [Tooling] CI hardening — npm cache, drop lockfile fallback, pin node, type-checked eslint, noImplicitOverride #76)toFileRecordmapper +QUARANTINE_DIR_NAMEconstant extracted (M27: [Major] Code duplication — FileRecord mapper in 3 places, _FileOrganizer_quarantine literal in 2 files #74)silentLogger/waitForScanStatus/drainhelpers; hostile-filename test (N3: [Minor] Test infrastructure — silentLogger / poll-loop / drain helpers + missing test cases #77)Closes
#53, #54, #55, #56, #57, #58, #59, #60, #61, #62, #63, #64, #65, #66, #67, #68, #69, #70, #71, #72, #73, #74, #75, #76, #77, #78
Test plan (manual verification)
The full test suite (430 tests) and code review have run; this is a checklist for behaviour you may want to eyeball before merging.
Scans
catalog.dbin a SQL viewer after the scan;files.ntfs_file_idis populated (non-NULL) on Windows; matches OS file ID for sample filesreconcileOnStartupruns without errorVOLUME_SERIAL_MISMATCH(HTTP 409 surfaced as a UI error)Organize / Undo
completedDRIVE_DISCONNECTED(UI shows 503) and no partial file remains at the destfailed, reason mentionspost_hash); source is not restoredDedupe
mklink /Hfrom cmd): scan, open Duplicates page; the hardlinked pair does NOT appear as a duplicatesamePhysicalFile: trueReconcile (crash recovery)
taskkillthe engine mid-organize; restart; engine logsreconcile-*events and the half-completed op is classified ascompleted(if dest matchespost_hash) orfailed(if source still present alongside completed dest)<drive>\_FileOrganizer_quarantine\manually; restart; engine warn-logsreconcile-quarantine-orphanCLI
npx tsx packages/engine/src/cli/index.ts --version→ prints package version, exits 0npx tsx packages/engine/src/cli/index.ts init --catalog=/tmp/x.db→ works (the=valueform)npx tsx packages/engine/src/cli/index.ts bogus→ prints "unknown command" + help to stderr, exits 2npx tsx packages/engine/src/cli/index.ts servethen Ctrl+C → shutdown messages appear with structured log linesAPI
curl http://127.0.0.1:<port>/api/files?limit=abc→ 200 with default-limited results (no NaN SQL crash)curl -X POST -H 'Content-Type: application/json' -d 'not json' http://127.0.0.1:<port>/api/rules→ 400{ "error": "invalid-json" }curl -X PUT -H 'Content-Type: application/json' -d '{"throttleProfiles":"junk"}' http://127.0.0.1:<port>/api/settings→ 400 with the zodpathnamedCatalog integrity
catalog.db(or useGet-FileHash-different copy fromnode); start engine; refuses to start, stderr containsCATALOG_CORRUPT+ the wiki linknpm run fetch-binaries, modify any bit ofmediainfobinary; re-run fetch — re-download happens (not validated for the extracted binary; only the zip is hashed) — verify with a tamperedMEDIAINFO_ZIP_SHA256constant that fetch refusesThrottle
throttle-changedevent published within ~60s of the boundary, not at the oldsetInterval-style driftKnown follow-ups (from the code-review audit)
These were surfaced by the post-implementation audit and deferred so this PR could land:
restorefailed branch, three onError typed-error branches (CatalogError/QuarantineError/IntegrityError),walker-realpath-errorlog assertioninsertOp/insertOpFullduplication inreconcile.test.ts, threeas anycasts inmove-cross-drive.test.ts(already documented), zod 4 changelog reference in PR body (this section),deletekind stub inPOST_CONDITIONScould use a TODO linkThe branch test count is 430 passing + 1 skipped (
metadata-videoshim test on win32) across all workspaces.Stats
zod@4(engine, for input validation),typescript-eslint@8(root, for type-checked rules)piexifjs(replaced by sharp'swithMetadataEXIF write)