import Foundation /// Tracks files already handed off — shared across watcher restarts. public actor ProcessedSkipList { private var processed = Set() public init() {} public func contains(_ path: String) -> Bool { processed.contains(path) } public func mark(_ path: String) { processed.insert(path) } } /// Polling watch folder. Emits file paths once they are: /// - matching extension /// - size-stable for `stabilityChecks` consecutive polls (partial-write guard) /// - not in the skip list /// Polling (not inotify): cheap at DIT-cart scale, identical behavior on /// network volumes where inotify is unreliable. public actor WatchFolder { private let path: String private let extensions: Set private let pollInterval: Duration private let stabilityChecks: Int private let skipList: ProcessedSkipList private var sizeHistory: [String: (size: UInt64, stableCount: Int)] = [:] private var task: Task? private var continuation: AsyncStream.Continuation? public init( path: String, extensions: [String], pollInterval: Duration = .seconds(2), stabilityChecks: Int = 2, skipList: ProcessedSkipList = ProcessedSkipList() ) { self.path = path self.extensions = Set(extensions.map { $0.lowercased() }) self.pollInterval = pollInterval self.stabilityChecks = stabilityChecks self.skipList = skipList } /// Start watching; returns stream of ready file paths. public func start() -> AsyncStream { let stream = AsyncStream { continuation in self.continuation = continuation } task = Task { while !Task.isCancelled { await pollOnce() try? await Task.sleep(for: pollInterval) } } return stream } public func stop() { task?.cancel() task = nil continuation?.finish() continuation = nil } private func pollOnce() async { let fm = FileManager.default guard let entries = try? fm.contentsOfDirectory(atPath: path) else { return } for entry in entries { let ext = (entry as NSString).pathExtension.lowercased() guard extensions.contains(ext) else { continue } let full = path + "/" + entry if await skipList.contains(full) { continue } guard let attrs = try? fm.attributesOfItem(atPath: full), let size = attrs[.size] as? UInt64 else { continue } if let prev = sizeHistory[full] { if prev.size == size { let stable = prev.stableCount + 1 if stable >= stabilityChecks { await skipList.mark(full) sizeHistory.removeValue(forKey: full) continuation?.yield(full) } else { sizeHistory[full] = (size, stable) } } else { sizeHistory[full] = (size, 0) // still growing } } else { sizeHistory[full] = (size, 0) } } } }