Files
nucleic/Sources/NucleicCore/MoveLog.swift
T
abkslmandClaude Opus 4.8 22c008bb46 Move: Smart Move logging, less-twitchy stall UI, harden iCloud prep
The "Downloading from iCloud" stall (sits at ~50-60% then suddenly jumps,
sometimes on a fully-downloaded tree) plus a "Canceling" that itself looked
stalled, and a request for examinable logs.

Stall UI is now far less sensitive and renamed:
- "Not progressing" → "Stalled".
- Stall threshold 12s → 45s, and the opaque phases now emit a per-tick liveness
  heartbeat (refresh updatedAt without moving the bar), so a slow-but-working
  move no longer flickers to the alarm. "Stalled" trips only on genuine silence.
- Cancel shows a calm "Canceling…" (MoveProgress.canceling, sticky across late
  heartbeats) instead of the stall alarm, and the Cancel button disables.

iCloud prep hardening (the actual wedge):
- downloadPlaceholders gives up on holdouts that make no *net* progress so the
  loop can't spin to its ceiling: a stale/orphan stub whose real file is missing
  bails (fast when iCloud reports it errored, after a grace otherwise) and the
  move proceeds. Progress is now monotonic and never negative even if the iCloud
  backlog grows/oscillates mid-run. Decision factored into a pure, unit-tested
  `downloadStep` (low-water-mark bail + monotonic clamp).
- resolveConflicts is now async, checks Task.isCancelled every file (Cancel is
  responsive instead of wedged on a big tree) and heartbeats during the scan.

Smart Move logging:
- New MoveLog (thread-safe, append-only, gated by a UserDefaults toggle) writes a
  detailed per-move/convert trace to ~/Library/Application Support/Nucleic/
  SmartMove.log (rotates at 2 MB). Threaded through the whole move + convert
  pipeline (phases, iCloud backlog, copy heartbeat, cancel, errors).
- Settings → General → Diagnostics: "Smart Move logging" toggle + Reveal Log in
  Finder.

Tests: MoveLog (enabled/disabled/rotation/close), MoveProgress.canceling,
classifyPending, downloadStep invariants (monotonic, oscillation/all-errored
bail, no false bail on steady progress), and relocator↔log wiring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 22:26:06 -07:00

117 lines
5.7 KiB
Swift

import Foundation
/// User-facing diagnostics switches, persisted in `UserDefaults`. Currently just the
/// Smart Move log toggle (Settings → General → Diagnostics). Lives in `NucleicCore` so the
/// move pipeline (`AppStore`, `ProjectRelocator`) and the SwiftUI layer agree on the key.
public enum MoveDiagnostics {
/// When on, the safe-move / Convert-to-Control pipeline writes a detailed trace to
/// `logFileURL` so a slow or stuck move can be examined after the fact. Off by default —
/// it's a diagnostic aid, not something to pay for on every move. See `MoveLog`.
public static let loggingEnabledKey = "nucleic.move.loggingEnabled"
/// Whether Smart Move logging is enabled app-wide.
public static var loggingEnabled: Bool {
UserDefaults.standard.bool(forKey: loggingEnabledKey)
}
/// `~/Library/Application Support/Nucleic/` — the same base the app store persists under
/// (see `NucleicApp.supportDirectory`), recomputed here so `NucleicCore` needn't be told.
public static var supportDirectory: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return base.appendingPathComponent("Nucleic", isDirectory: true)
}
/// The rolling Smart Move log file, created lazily by `MoveLog` when logging is on.
public static var logFileURL: URL {
supportDirectory.appendingPathComponent("SmartMove.log")
}
/// The previous log, kept across one rotation so a just-finished move isn't lost when the
/// next one rolls a large file over.
public static var previousLogFileURL: URL {
supportDirectory.appendingPathComponent("SmartMove.previous.log")
}
}
/// A thread-safe, append-only text logger for a single safe-move / Convert-to-Control run,
/// gated by `MoveDiagnostics.loggingEnabled`. When logging is off every method is a cheap
/// no-op, so the move pipeline can call it unconditionally.
///
/// Writes are serialized behind a lock because the pipeline logs from the main actor, a
/// background liveness `Task`, and detached copy / directory-size walks all at once. One shared
/// rolling file (`MoveDiagnostics.logFileURL`); each run brackets its lines with a header /
/// footer and tags every line with its short `label`, so interleaved moves of different projects
/// stay legible and finished runs remain on disk for comparison.
public final class MoveLog: @unchecked Sendable {
private let label: String
private let start: Date
private let lock = NSLock()
/// nil when logging is disabled or after `finish()`; guarded by `lock`.
private var handle: FileHandle?
/// The on-disk log file, or nil when logging is disabled / the file couldn't be opened.
public let fileURL: URL?
/// Whether this logger is actually writing (logging on and the file opened).
public var isEnabled: Bool { fileURL != nil }
/// Open (creating / rotating as needed) the shared move log and write a run header. Reads the
/// global `MoveDiagnostics.loggingEnabled` toggle once — a move is short-lived, so a mid-move
/// toggle change needn't take effect. `label` is a short human tag (e.g. the project name).
public convenience init(label: String) {
self.init(label: label,
directory: MoveDiagnostics.supportDirectory,
enabled: MoveDiagnostics.loggingEnabled)
}
/// Designated initializer with an explicit directory + enabled flag, so tests can write to a
/// temp dir without touching real Application Support or the global toggle.
init(label: String, directory: URL, enabled: Bool) {
self.label = label
self.start = Date()
guard enabled else { handle = nil; fileURL = nil; return }
let fm = FileManager.default
let url = directory.appendingPathComponent("SmartMove.log")
try? fm.createDirectory(at: directory, withIntermediateDirectories: true)
// Rotate a large log so the file stays openable in a text editor.
if let size = (try? fm.attributesOfItem(atPath: url.path)[.size]) as? Int, size > 2_000_000 {
let rolled = directory.appendingPathComponent("SmartMove.previous.log")
try? fm.removeItem(at: rolled)
try? fm.moveItem(at: url, to: rolled)
}
if !fm.fileExists(atPath: url.path) { fm.createFile(atPath: url.path, contents: nil) }
let opened = try? FileHandle(forWritingTo: url)
_ = try? opened?.seekToEnd()
self.handle = opened
self.fileURL = opened == nil ? nil : url
line("════════ move “\(label)” started ════════")
}
/// Append one timestamped, run-tagged line. Thread-safe; a no-op when logging is off.
public func line(_ message: String) {
let stamp = Self.stampFormatter.string(from: Date())
let elapsed = String(format: "%7.1fs", Date().timeIntervalSince(start))
let text = "[\(stamp)] [+\(elapsed)] [\(label)] \(message)\n"
guard let data = text.data(using: .utf8) else { return }
lock.lock(); defer { lock.unlock() }
guard let handle else { return }
try? handle.write(contentsOf: data)
}
/// Write a footer and close the file. Safe to call once; later `line` calls become no-ops
/// (the handle is dropped under the lock, so a late async callback can't write to a closed fd).
public func finish(_ summary: String) {
line("──────── move “\(label)” finished: \(summary) ────────")
lock.lock(); defer { lock.unlock() }
try? handle?.close()
handle = nil
}
private static let stampFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return f
}()
}