1452d201ee6b
give every failed Doctor check a remedy and offer repairs that lose nothing (3.8.5)
- time
- 2026-10-06 23:01 UTC · 6 days ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- doctor remedies and repairs
- parent
- 176fe5976767
↓ koh-1452d201.tar.gzatlas at this save
files changed11 files
README.md+3 −1VERSION+1 −1src/cli/cmd_doctor.v+183 −35src/cli/cmd_doctor_test.v+136 −0src/cli/doctor_checks.v+33 −8src/cli/doctor_remote.v+21 −11src/cli/doctor_repair.v+163 −0src/exchange/repair.v+132 −0src/storage/objects.v+46 −7tests/cli_test.v+17 −0v.mod+1 −1README.md+3 −1
@@ -177,7 +177,9 @@177177 `doctor` checks the database, stored content, history, events, working files178178 and configuration. It also verifies remote history and release files by default.179179 Use `--fast` for a local check. The report names each check, its result,180-and any part it could not verify.180+and any part it could not verify. A failed check says how to fix it, and181+Doctor offers to repair what it can without losing anything: on a terminal it182+asks first, and `--yes` runs every repair.181183 Its backup command writes a consistent copy of `.koh`. Use it instead of182184 copying the database while Koh is using it. Keep a live `.koh` out of cloud183185 folder sync services; use `offer` and `steal` between machines.src/cli/cmd_doctor.v+183 −35
@@ -1,7 +1,8 @@1-//! Repository checks, remote verification and explicit maintenance.1+//! Repository checks, remote verification, repairs and explicit maintenance.22 //!3-//! Remote checks only read. Cache resets are automatic; --yes permits4-//! old-peek cleanup and --backup writes a consistent database copy.3+//! Checks only read. Cache resets are automatic; repairs run only with4+//! consent; --yes also permits old-peek cleanup and --backup writes a5+//! consistent database copy.56 67 module cli78 @@ -25,13 +26,71 @@2526 health: 'unhealthy'2627 root: storage.find_root(os.getwd()) or { os.getwd() }2728 problems: [err.msg()]28- checks: [DoctorCheck{'database open', 'fail', err.msg()}]29+ checks: [DoctorCheck{'database open', 'fail', err.msg(), open_remedy(err.msg())}]2930 scope: 'database could not be opened; further checks could not run'3031 }31- if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }32+ if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report, true) }3233 return 13334 }3435 defer { store.close() }36+ yes, fast := args.flag('yes'), args.flag('fast')37+ // Before any repair, so the copy holds the repo as Doctor found it.38+ backup_path := args.value_or_none('backup') or { '' }39+ mut backup_error := ''40+ if backup_path != '' {41+ store.backup(backup_path) or { backup_error = err.msg() }42+ }43+ mut report := diagnose(store, fast)44+ repairs := plan_repairs(store, report)45+ report.repairs = repairs.map(it.action)46+ mut repaired := []string{}47+ mut repair_errors := []string{}48+ if repairs.len > 0 && (yes || (interactive() && !args.json())) {49+ if !yes {50+ print_doctor(report, false)51+ println('')52+ }53+ for repair in repairs {54+ if !yes {55+ println(' repair: ${repair.action}')56+ }57+ if !(confirm(yes) or { false }) {58+ continue59+ }60+ repair.run() or {61+ repair_errors << '${repair.action}: ${err.msg()}'62+ continue63+ }64+ repaired << repair.action65+ }66+ if repaired.len > 0 {67+ report = diagnose(store, fast)68+ report.repairs = plan_repairs(store, report).map(it.action)69+ }70+ }71+ report.repaired = repaired72+ report.repair_errors = repair_errors73+ if yes {74+ report.peek_folders_removed = sweep_peek_folders()75+ }76+ if backup_error != '' {77+ report.check('backup', [backup_error])78+ } else {79+ report.backup = backup_path80+ }81+ report.health = if report.problems.len > 0 {82+ 'unhealthy'83+ } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }84+ if args.json() {85+ emit_json(json2.encode(report))86+ } else if repairs.len == 0 || yes || repaired.len > 0 || !interactive() {87+ print_doctor(report, true)88+ }89+ return if report.problems.len > 0 { 1 } else { 0 }90+}91+92+// diagnose runs every check; it changes nothing but the disposable hash cache.93+fn diagnose(store &storage.Store, fast bool) DoctorJson {3594 mut report := DoctorJson{3695 root: store.root3796 database: os.join_path(store.root, storage.file_name)@@ -40,57 +99,123 @@4099 koh_version: version41100 }42101 working('checking database, objects and all stored history')43- store.db.exec('BEGIN') or { return refuse(args.json(), err.msg()) }102+ store.db.exec('BEGIN') or {103+ report.check('read transaction', [err.msg()])104+ return report105+ }44106 report.check('SQLite integrity', store.integrity_check() or { [err.msg()] })45- report.check('object hashes and sizes', store.check_objects() or { [err.msg()] })46- report.check('save object references', store.check_save_files() or { [err.msg()] })107+ report.object_check('object hashes and sizes', store.check_objects() or {108+ [storage.ObjectProblem{'', err.msg()}]109+ })110+ report.object_check('save object references', store.check_save_files() or {111+ [storage.ObjectProblem{'', err.msg()}]112+ })47113 doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) }48114 store.end() or { report.check('read transaction', [err.msg()]) }49115 working('reading working files without cached hashes')50116 doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) }51117 doctor_build(store.root, mut report)52- if args.flag('yes') {53- report.peek_folders_removed = sweep_peek_folders()54- }55- if path := args.value_or_none('backup') {56- mut backed_up := true57- store.backup(path) or {58- report.check('backup', [err.msg()])59- backed_up = false60- }61- if backed_up {62- report.backup = path63- }64- }65118 report.size_bytes = os.file_size(report.database)66119 report.wal_bytes = os.file_size('${report.database}-wal')67120 doctor_remote_config(mut report)68- if args.flag('fast') {69- report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked'}121+ if fast {122+ report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked', ''}70123 } else {71124 working('auditing remote history and releases (read-only)')72125 doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }73126 }74127 report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'75- report.health = if report.problems.len > 0 {76- 'unhealthy'77- } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }78- if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }79- return if report.problems.len > 0 { 1 } else { 0 }128+ return report80129 }81130 82131 fn (mut report DoctorJson) check(name string, problems []string) {83- report.checks << DoctorCheck{name, if problems.len == 0 { 'pass' } else { 'fail' }, if problems.len == 0 {84- 'verified'85- } else {132+ failed := problems.len > 0133+ report.checks << DoctorCheck{name, if failed { 'fail' } else { 'pass' }, if failed {86134 problems.join('; ')87- }}135+ } else {136+ 'verified'137+ }, if failed { remedy_for(name) } else { '' }}88138 for problem in problems {89139 if problem !in report.problems { report.problems << problem }90140 }91141 }92142 93-fn print_doctor(report DoctorJson) {143+// An object problem with no hash is a check that could not run.144+fn (mut report DoctorJson) object_check(name string, problems []storage.ObjectProblem) {145+ for problem in problems {146+ if problem.hash != '' && problem.hash !in report.damaged_objects {147+ report.damaged_objects << problem.hash148+ }149+ }150+ report.check(name, problems.map(it.detail))151+}152+153+// What to do when a check fails. A problem that needs its own command, such as154+// a strip of one unsafe path, names it in its text.155+fn remedy_for(check string) string {156+ return match check {157+ 'SQLite integrity' {158+ 'koh doctor can rebuild the indexes; if damage remains, restore a copy made with koh doctor --backup, or rebuild from the remote with koh steal <repo> in an empty folder'159+ }160+ 'object hashes and sizes', 'save object references' {161+ 'koh doctor restores verified copies from working files or the remote; with neither, that content is lost'162+ }163+ 'history links and cycles' {164+ 'koh doctor fetches missing saves from the remote; a cycle needs a backup or the remote copy'165+ }166+ 'saved paths and events' {167+ 'run the koh strip command shown for an unsafe path, or strip one side of a file/folder collision; koh doctor re-applies recorded strips; a bad event needs a backup or the remote copy'168+ }169+ 'restore state' {170+ 'run koh revert; it keeps the current files in a checkpoint first'171+ }172+ 'working tree safety' {173+ 'rename files whose names Koh cannot save, and move links that block a restore'174+ }175+ 'ignore rules' {176+ 'make the ignore file readable'177+ }178+ 'build configuration' {179+ 'fix koh.build; koh man build describes it'180+ }181+ 'machine remote', 'machine remote configuration' {182+ 'run koh remote to set the remote and its credentials again'183+ }184+ 'remote audit' {185+ 'check the network and the remote settings (koh remote), then run koh doctor again'186+ }187+ 'remote history, events and objects' {188+ 'koh doctor uploads what this repo holds; anything else must be offered from a machine that has it'189+ }190+ 'remote releases' {191+ 'publish that version again with koh offer --builds from the save it names'192+ }193+ 'remote stability' {194+ 'another machine changed the remote during the audit; run koh doctor again'195+ }196+ 'backup' {197+ 'choose a backup path that does not exist yet'198+ }199+ else {200+ 'run koh doctor again; if it repeats, restore a copy made with koh doctor --backup, or rebuild from the remote with koh steal <repo> in an empty folder'201+ }202+ }203+}204+205+fn open_remedy(message string) string {206+ return if message.contains('newer koh') {207+ 'run koh update'208+ } else if message.contains('another koh command') || message.contains('locked') {209+ 'wait for the other koh command to finish, then run koh doctor again'210+ } else if message.contains('koh init') {211+ 'run koh init here, or koh steal <repo> to fetch one'212+ } else {213+ 'keep .koh as it is; restore a copy made with koh doctor --backup, or rebuild from the remote with koh steal <repo> in an empty folder'214+ }215+}216+217+// offer lists the repairs left to run; it is false just before Doctor asks about them.218+fn print_doctor(report DoctorJson, offer bool) {94219 root := report.root.replace_once(os.home_dir(), '~')95220 if report.problems.len > 0 {96221 warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')@@ -120,6 +245,9 @@120245 else { fmt.amber('!') }121246 }122247 println(' ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')248+ if check.remedy != '' {249+ println(' fix: ${check.remedy}')250+ }123251 }124252 for warning in report.warnings {125253 warn(warning)@@ -128,9 +256,20 @@128256 ok('removed ${report.peek_folders_removed} old peek folders')129257 }130258 if report.backup != '' { ok('backed up to ${report.backup}') }131- if report.problems.len > 0 {132- note('keep .koh intact before recovering history; use a known-good backup or another verified copy')259+ for action in report.repaired {260+ ok('repaired: ${action}')133261 }262+ for failure in report.repair_errors {263+ warn('repair failed: ${failure}')264+ }265+ if offer && report.repairs.len > 0 {266+ println('')267+ warn('koh doctor can repair ${fmt.plural(report.repairs.len, 'finding')}:')268+ for action in report.repairs {269+ println(' ${action}')270+ }271+ note('run koh doctor again to choose, or koh doctor --yes to run them all')272+ }134273 println('')135274 note(report.scope)136275 }@@ -223,6 +362,7 @@223362 name string224363 status string225364 detail string365+ remedy string226366 }227367 228368 struct DoctorJson {@@ -274,4 +414,12 @@274414 cache_cleared bool275415 peek_folders_removed int276416 backup string417+ repairs []string418+ repaired []string419+ repair_errors []string420+ damaged_objects []string421+ missing_saves []string422+ strip_residue bool423+ remote_bad_objects []string424+ remote_missing_saves []string277425 }src/cli/cmd_doctor_test.v+136 −0
@@ -140,3 +140,139 @@140140 assert report.problems.any(it.contains('unknown kind'))141141 assert report.problems.any(it.contains('stripped path'))142142 }143+144+struct DoctorMemory {145+mut:146+ items map[string][]u8147+}148+149+fn (mut backend DoctorMemory) get(key string) !remote.Object {150+ if body := backend.items[key] {151+ return remote.Object{ found: true, body: body }152+ }153+ return remote.Object{}154+}155+156+fn (mut backend DoctorMemory) list(prefix string) ![]string {157+ return backend.items.keys().filter(it.starts_with('${prefix}/'))158+}159+160+fn (mut backend DoctorMemory) put_if_absent(key string, body []u8) !bool {161+ if key in backend.items {162+ return false163+ }164+ backend.items[key] = body165+ return true166+}167+168+fn (mut backend DoctorMemory) put_if_match(key string, body []u8, tag string) !bool {169+ return false170+}171+172+fn (mut backend DoctorMemory) delete(key string) ! {173+ backend.items.delete(key)174+}175+176+fn doctor_store(name string) !(storage.Store, string) {177+ dir := os.join_path(os.temp_dir(), 'koh3_doctor_${name}_${os.getpid()}')178+ os.rmdir_all(dir) or {}179+ os.mkdir_all(dir)!180+ return storage.create(dir)!, dir181+}182+183+fn test_doctor_fetches_missing_parents_from_the_remote_without_writing_to_it() {184+ store, dir := doctor_store('fetch')!185+ defer {186+ store.close()187+ os.rmdir_all(dir) or {}188+ }189+ data := 'parent'.bytes()190+ hash := storage.hash_hex(data)191+ mut backend := DoctorReadOnly{192+ items: {193+ 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{194+ id: 'aaaaaaaaaaaa'195+ files: [exchange.FileRecord{ path: 'file', hash: hash }]196+ }).bytes()197+ 'repo/objects/${hash}': zlib.compress(data)!198+ }199+ }200+ store.db.exec('PRAGMA foreign_keys=OFF')!201+ store.begin()!202+ history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },203+ []snapshot.FileEntry{})!204+ history.set_head(store, 'bbbbbbbbbbbb')!205+ store.end()!206+ store.db.exec('PRAGMA foreign_keys=ON')!207+ missing := missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb')208+ assert missing == ['aaaaaaaaaaaa']209+ assert exchange.fetch_missing(store, mut backend, 'repo', missing)! == 1210+ assert missing_saves(history.all_saves(store)!, 'bbbbbbbbbbbb').len == 0211+ assert store.get_object(hash)! == data212+ assert exchange.fetch_missing(store, mut backend, 'repo', ['cccccccccccc']) or {213+ assert err.msg().contains('does not have save')214+ 0215+ } == 0216+}217+218+fn test_doctor_reapplies_recorded_strips_and_finds_the_only_tip() {219+ store, dir := doctor_store('strip')!220+ defer {221+ store.close()222+ os.rmdir_all(dir) or {}223+ }224+ secret := store.put_object('secret'.bytes())!225+ kept := store.put_object('kept'.bytes())!226+ store.begin()!227+ history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [228+ snapshot.FileEntry{ path: 'keep', hash: kept },229+ snapshot.FileEntry{ path: 'private/key', hash: secret },230+ ])!231+ history.import_save(store, history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' },232+ [snapshot.FileEntry{ path: 'keep', hash: kept }])!233+ events.strip(store, '', 'private')!234+ store.end()!235+ assert only_tip(store) or { '' } == 'bbbbbbbbbbbb'236+ mut report := DoctorJson{}237+ doctor_files('aaaaaaaaaaaa', history.files_of(store, 'aaaaaaaaaaaa')!, events.all(store)!, mut238+ report)239+ assert report.strip_residue240+ assert exchange.reapply_strips(store)! == 1241+ assert history.files_of(store, 'aaaaaaaaaaaa')!.map(it.path) == ['keep']242+ assert !store.has_object(secret)!243+ assert events.strip_events(store)!.len == 1244+}245+246+fn test_doctor_restores_objects_from_working_files_and_resends_them_to_the_remote() {247+ store, dir := doctor_store('objects')!248+ defer {249+ store.close()250+ os.rmdir_all(dir) or {}251+ }252+ data := 'content'.bytes()253+ hash := store.put_object(data)!254+ store.begin()!255+ history.import_save(store, history.Save{ id: 'aaaaaaaaaaaa' }, [256+ snapshot.FileEntry{ path: 'app', hash: hash },257+ ])!258+ history.set_head(store, 'aaaaaaaaaaaa')!259+ store.end()!260+ store.db.exec("UPDATE objects SET data = x'00'")!261+ problems := store.check_objects()!262+ assert problems.map(it.hash) == [hash]263+ assert local_copy(store, hash) == none264+ os.write_file(os.join_path(dir, 'app'), 'content')!265+ store.repair_object(hash, local_copy(store, hash) or { panic('no working copy') })!266+ assert store.check_objects()!.len == 0267+ store.repair_object(hash, 'other'.bytes()) or {268+ assert err.msg().contains('does not match')269+ }270+ mut backend := DoctorMemory{271+ items: {272+ 'repo/objects/${hash}': 'damaged'.bytes()273+ }274+ }275+ assert exchange.resend(store, mut backend, 'repo', [hash], ['aaaaaaaaaaaa'])! == 2276+ assert zlib.decompress(backend.items['repo/objects/${hash}'])! == data277+ assert exchange.decode_save(backend.items['repo/saves/aaaaaaaaaaaa'])!.files[0].hash == hash278+}src/cli/doctor_checks.v+33 −8
@@ -3,6 +3,7 @@33 44 import os55 import src.build6+import src.fmt67 import src.events78 import src.history89 import src.snapshot@@ -41,6 +42,26 @@4142 return problems4243 }4344 45+// Saves that history names (as parent, restored save or head) but does not hold.46+fn missing_saves(saves []history.Save, head string) []string {47+ mut known := map[string]bool{}48+ for save in saves {49+ known[save.id] = true50+ }51+ mut missing := []string{}52+ for save in saves {53+ for id in [save.parent, save.restores] {54+ if id != '' && id !in known && id !in missing {55+ missing << id56+ }57+ }58+ }59+ if head != '' && head !in known && head !in missing {60+ missing << head61+ }62+ return missing63+}64+4465 fn doctor_files(id string, files []snapshot.FileEntry, event_list []events.Event,4566 mut report DoctorJson) {4667 mut paths := map[string]bool{}@@ -64,6 +85,7 @@6485 && (event.save_id == '' || event.save_id == id)6586 && (file.path == event.path || file.path.starts_with('${event.path.trim_right('/')}/')) {6687 report.problems << 'save ${id} still contains stripped path ${snapshot.escape_path(file.path)}'88+ report.strip_residue = true6789 }6890 }6991 }@@ -83,7 +105,7 @@83105 }84106 // Sync may retain an event for an off-line save this receiver never fetched.85107 if event.save_id != '' && event.save_id !in ids {86- report.warnings << 'event ${event.id} targets unavailable save ${event.save_id}'108+ report.warnings << 'event ${event.id} targets unavailable save ${event.save_id}; nothing to do'87109 }88110 if event.kind in [events.kind_flag, events.kind_clear] {89111 old := flags[event.save_id] or { events.Event{} }@@ -96,7 +118,9 @@96118 report.flagged_saves = flags.values().filter(it.kind == events.kind_flag).len97119 if head_flag := flags[report.head_id] {98120 report.head_flagged = head_flag.kind == events.kind_flag99- if report.head_flagged { report.warnings << 'head is flagged: ${head_flag.reason}' }121+ if report.head_flagged {122+ report.warnings << 'head is flagged: ${head_flag.reason}; koh flag --clear removes the flag'123+ }100124 }101125 }102126 @@ -120,6 +144,7 @@120144 report.check('references and metadata', stats.problems)121145 saves := history.all_saves(store)!122146 report.check('history links and cycles', doctor_history(saves, report.head_id))147+ report.missing_saves = missing_saves(saves, report.head_id)123148 mut known := map[string]history.Save{}124149 for save in saves {125150 known[save.id] = save@@ -142,7 +167,7 @@142167 for save in saves {143168 doctor_files(save.id, history.files_of(store, save.id)!, event_list, mut report)144169 if save.replayed_from != '' && save.replayed_from !in known {145- report.warnings << 'save ${save.id} was replayed from unavailable save ${save.replayed_from}'170+ report.warnings << 'save ${save.id} was replayed from unavailable save ${save.replayed_from}; nothing to do'146171 }147172 }148173 report.check('saved paths and events', report.problems[before..].clone())@@ -155,7 +180,7 @@155180 report.warnings << 'remote strip unfinished for ${snapshot.escape_path(path)}; retry koh strip ${shell_quote(path)}'156181 }157182 if stats.unused > 0 {158- report.warnings << '${stats.unused} unreferenced objects retained; Doctor does not remove history'183+ report.warnings << '${fmt.plural(int(stats.unused), 'unreferenced object')} retained; no save uses them and koh doctor can remove them'159184 }160185 }161186 @@ -163,7 +188,7 @@163188 store.drop_cache()!164189 report.cache_cleared = true165190 if !report.metadata_available || (report.saves > 0 && report.head.id == '') {166- report.checks << DoctorCheck{'working tree', 'skipped', 'head is missing'}191+ report.checks << DoctorCheck{'working tree', 'skipped', 'head is missing', ''}167192 return168193 }169194 for path in [os.join_path(store.root, snapshot.ignore_file), snapshot.global_ignore_path()] {@@ -186,15 +211,15 @@186211 problems << 'unsafe working name ${path}; this file cannot be saved'187212 }188213 report.check('working tree safety', problems)189- report.checks << DoctorCheck{'working tree changes', 'pass', '${report.changes.added.len} added, ${report.changes.modified.len} modified, ${report.changes.removed.len} removed'}214+ report.checks << DoctorCheck{'working tree changes', 'pass', '${report.changes.added.len} added, ${report.changes.modified.len} modified, ${report.changes.removed.len} removed', ''}190215 if report.ignored_saved_files.len > 0 {191- report.warnings << '${report.ignored_saved_files.len} saved files now match ignore rules; older versions remain in history'216+ report.warnings << 'ignore rules now cover ${fmt.plural(report.ignored_saved_files.len, 'saved file')}; older versions remain in history, and koh strip removes them if they must go'192217 }193218 }194219 195220 fn doctor_build(root string, mut report DoctorJson) {196221 if !os.exists(os.join_path(root, build.config_name)) {197- report.checks << DoctorCheck{'build configuration', 'skipped', 'no koh.build'}222+ report.checks << DoctorCheck{'build configuration', 'skipped', 'no koh.build', ''}198223 return199224 }200225 config := build.read_config(root) or {src/cli/doctor_remote.v+21 −11
@@ -16,7 +16,7 @@1616 1717 fn doctor_remote_config(mut report DoctorJson) {1818 if !machine.has_remote() {19- report.checks << DoctorCheck{'machine remote', 'skipped', 'no machine remote configured'}19+ report.checks << DoctorCheck{'machine remote', 'skipped', 'no machine remote configured', ''}2020 return2121 }2222 settings := machine.load_remote() or {@@ -35,25 +35,31 @@3535 3636 fn doctor_remote(store &storage.Store, mut report DoctorJson) ! {3737 if report.source == '' && !machine.has_remote() {38- report.checks << DoctorCheck{'remote audit', 'skipped', 'no remote or source configured'}38+ report.checks << DoctorCheck{'remote audit', 'skipped', 'no remote or source configured', ''}3939 return4040 }41- mut backend := if report.source != '' {42- exchange.read_only(report.source)!41+ mut backend, repo := doctor_backend(store, report.source, report.remote_repo)!42+ doctor_remote_content(mut backend, repo, mut report)!43+}44+45+// The remote Doctor reads: the repo's source when it has one, else this machine's remote.46+fn doctor_backend(store &storage.Store, source string, remote_repo string) !(remote.Backend, string) {47+ backend := if source != '' {48+ exchange.read_only(source)!4349 } else {4450 this_machine_backend()!4551 }46- repo := if report.source != '' {47- report.source.all_after_last('/')48- } else if report.remote_repo != '' {49- report.remote_repo52+ repo := if source != '' {53+ source.all_after_last('/')54+ } else if remote_repo != '' {55+ remote_repo5056 } else {5157 os.base(store.root).to_lower()5258 }5359 if !snapshot.legal_path(repo) || repo.contains('/') {5460 return error('unsafe remote repo name')5561 }56- doctor_remote_content(mut backend, repo, mut report)!62+ return backend, repo5763 }5864 5965 fn doctor_remote_content(mut backend remote.Backend, repo string, mut report DoctorJson) ! {@@ -114,6 +120,7 @@114120 }115121 mut remote_report := DoctorJson{ head_id: report.remote_head }116122 remote_report.problems = doctor_history(saves, report.remote_head)123+ report.remote_missing_saves = missing_saves(saves, report.remote_head)117124 doctor_events(event_list, saves, mut remote_report)118125 for save in saves {119126 doctor_files(save.id, files[save.id], event_list, mut remote_report)@@ -127,14 +134,17 @@127134 object := backend.get(exchange.object_key(repo, hash))!128135 if !object.found {129136 remote_report.problems << 'missing remote object ${hash}'137+ report.remote_bad_objects << hash130138 continue131139 }132140 data := zlib.decompress(object.body) or {133141 remote_report.problems << 'damaged remote object ${hash}'142+ report.remote_bad_objects << hash134143 continue135144 }136145 if storage.hash_hex(data) != hash {137146 remote_report.problems << 'remote object ${hash} fails Blake3 verification'147+ report.remote_bad_objects << hash138148 }139149 }140150 report.remote_objects = hashes.len@@ -143,7 +153,7 @@143153 report.warnings << remote_report.warnings.map('remote: ${it}')144154 remote_report.problems = []string{}145155 if mut backend is remote.Web {146- report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings'}156+ report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings', ''}147157 } else {148158 latest := backend.get(release.latest_key(repo))!149159 mut versions := []string{}@@ -192,7 +202,7 @@192202 report.check('remote stability', ['remote head changed during audit; retry Doctor'])193203 }194204 if !head.found {195- report.warnings << 'remote has no head for ${repo}; it may never have been offered or may have been renamed'205+ report.warnings << 'remote has no head for ${repo}; it may never have been offered or may have been renamed, and koh offer sends this line'196206 }197207 if report.remote_head != '' && report.remote_head != report.head_id {198208 report.warnings << 'local and remote heads differ; inspect koh steal before applying changes'src/cli/doctor_repair.v+163 −0
@@ -0,0 +1,163 @@1+//! Doctor's repairs (spec §7.6.2). Each one puts back verified data or2+//! finishes interrupted work; none changes what a save contains.3+4+module cli5+6+import compress.zlib7+import os8+import src.exchange9+import src.fmt10+import src.history11+import src.machine12+import src.snapshot13+import src.storage14+15+struct DoctorRepair {16+ action string17+ run fn () ! @[required]18+}19+20+fn plan_repairs(store &storage.Store, report DoctorJson) []DoctorRepair {21+ mut repairs := []DoctorRepair{}22+ has_remote := report.source != '' || machine.has_remote()23+ // A source is read-only, so only this machine's own remote takes uploads.24+ writable_remote := report.source == '' && machine.has_remote()25+ source, remote_repo := report.source, report.remote_repo26+ if report.checks.any(it.name == 'SQLite integrity' && it.status == 'fail') {27+ repairs << DoctorRepair{'rebuild the database indexes', fn [store] () ! {28+ store.db.exec('REINDEX')!29+ }}30+ }31+ if report.damaged_objects.len > 0 {32+ hashes := report.damaged_objects33+ repairs << DoctorRepair{'restore ${fmt.plural(hashes.len, 'damaged or missing object')} from verified copies', fn [store, hashes, source, remote_repo] () ! {34+ repair_objects(store, hashes, source, remote_repo)!35+ }}36+ }37+ if report.missing_saves.len > 0 && has_remote {38+ ids := report.missing_saves39+ repairs << DoctorRepair{'fetch ${fmt.plural(ids.len, 'missing save')} from the remote', fn [store, ids, source, remote_repo] () ! {40+ mut backend, repo := doctor_backend(store, source, remote_repo)!41+ exchange.fetch_missing(store, mut backend, repo, ids)!42+ }}43+ } else if report.metadata_available && report.saves > 0 && report.head.id == '' {44+ if tip := only_tip(store) {45+ repairs << DoctorRepair{'point head at [${tip}], the only save nothing follows', fn [store, tip] () ! {46+ history.set_head(store, tip)!47+ }}48+ }49+ }50+ if report.strip_residue {51+ repairs << DoctorRepair{'remove stripped paths that saves still hold', fn [store] () ! {52+ exchange.reapply_strips(store)!53+ }}54+ }55+ if report.restore_unfinished {56+ repairs << DoctorRepair{'finish the interrupted restore (koh revert keeps the current files in a checkpoint)', fn [store] () ! {57+ finish_restore(store)!58+ }}59+ }60+ if report.pending_strips.len > 0 && writable_remote {61+ paths := report.pending_strips62+ repairs << DoctorRepair{'send ${fmt.plural(paths.len, 'unfinished strip')} to the remote', fn [store, paths, remote_repo] () ! {63+ mut backend, _ := doctor_backend(store, '', remote_repo)!64+ for path in paths {65+ exchange.send_strip(store, mut backend, path)!66+ }67+ }}68+ }69+ // With damaged rows an unused object may be the only copy of saved content.70+ if report.unreferenced_objects > 0 && report.problems.len == 0 {71+ repairs << DoctorRepair{'delete ${fmt.plural(int(report.unreferenced_objects), 'object')} no save uses', fn [store] () ! {72+ store.begin()!73+ store.drop_unreferenced_objects() or {74+ store.rollback()75+ return err76+ }77+ store.end()!78+ }}79+ }80+ if writable_remote {81+ hashes := report.remote_bad_objects.filter(stored_intact(store, it))82+ ids := report.remote_missing_saves.filter(history.by_id(store, it) != none)83+ if hashes.len + ids.len > 0 {84+ repairs << DoctorRepair{'upload ${fmt.plural(hashes.len, 'object')} and ${fmt.plural(ids.len, 'save')} the remote lacks or holds damaged', fn [store, hashes, ids, remote_repo] () ! {85+ mut backend, repo := doctor_backend(store, '', remote_repo)!86+ exchange.resend(store, mut backend, repo, hashes, ids)!87+ }}88+ }89+ }90+ return repairs91+}92+93+// Each copy must hash back to its key before it replaces anything: first the94+// stored bytes (only the size was wrong), then working files, then the remote.95+fn repair_objects(store &storage.Store, hashes []string, source string, remote_repo string) ! {96+ mut lost := []string{}97+ for hash in hashes {98+ if data := local_copy(store, hash) {99+ store.repair_object(hash, data)!100+ continue101+ }102+ if source == '' && !machine.has_remote() {103+ lost << hash104+ continue105+ }106+ mut backend, repo := doctor_backend(store, source, remote_repo)!107+ blob := backend.get(exchange.object_key(repo, hash))!108+ data := zlib.decompress(blob.body) or { []u8{} }109+ if !blob.found || storage.hash_hex(data) != hash {110+ lost << hash111+ continue112+ }113+ store.repair_object(hash, data)!114+ }115+ if lost.len > 0 {116+ return error('no verified copy here or on the remote of ${lost.join(', ')}')117+ }118+}119+120+fn stored_intact(store &storage.Store, hash string) bool {121+ store.get_object(hash) or { return false }122+ return true123+}124+125+fn local_copy(store &storage.Store, hash string) ?[]u8 {126+ if data := store.get_object(hash) {127+ return data128+ }129+ for path in store.paths_of_object(hash) or { return none } {130+ if !snapshot.legal_path(path) {131+ continue132+ }133+ data := os.read_bytes(os.join_path(store.root, path)) or { continue }134+ if storage.hash_hex(data) == hash {135+ return data136+ }137+ }138+ return none139+}140+141+// A head lost with no remote copy can only be guessed safely when exactly one142+// save has no successor.143+fn only_tip(store &storage.Store) ?string {144+ saves := history.all_saves(store) or { return none }145+ mut parents := map[string]bool{}146+ for save in saves {147+ parents[save.parent] = true148+ }149+ tips := saves.filter(it.id !in parents)150+ return if tips.len == 1 { tips[0].id } else { none }151+}152+153+// The same steps as koh revert, without its output, which would break --json.154+fn finish_restore(store &storage.Store) ! {155+ head := history.head(store) or { return error('this repo has no head to restore') }156+ if !compare_with_head(store, head)!.dirty {157+ snapshot.clear_restore_mark(store)!158+ return159+ }160+ plan := plan_restore(store, head)!161+ apply_restore(store, head, 'revert', plan, true)!162+ snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files, plan.remove)!163+}src/exchange/repair.v+132 −0
@@ -0,0 +1,132 @@1+//! Doctor's repairs that copy records between this repo and its remote.2+//!3+//! Each one only puts back what another copy of the repo verifiably holds;4+//! none changes what a save contains.5+6+module exchange7+8+import compress.zlib9+import src.events10+import src.history11+import src.remote12+import src.storage13+14+/// Fetch saves this repo is missing, with any missing ancestors and objects,15+/// in one transaction.16+pub fn fetch_missing(store &storage.Store, mut backend remote.Backend, repo string, ids []string) !int {17+ mut fetched := Fetched{}18+ mut wanted := ids.clone()19+ mut seen := map[string]bool{}20+ for wanted.len > 0 {21+ id := wanted.pop()22+ if id == '' || id in seen || history.by_id(store, id) != none {23+ continue24+ }25+ seen[id] = true26+ object := backend.get(save_key(repo, id))!27+ if !object.found {28+ return error('the remote does not have save ${id} either')29+ }30+ record := decode_save(object.body)!31+ if record.id != id {32+ return error('remote save ${id} holds the record of ${record.id}')33+ }34+ for file in record.files {35+ if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects {36+ blob := backend.get(object_key(repo, file.hash))!37+ if !blob.found {38+ return error('the remote is missing object ${file.hash} that save ${id} needs')39+ }40+ fetched.repo_objects[file.hash] = blob.body41+ }42+ }43+ fetched.saves << record44+ wanted << record.parent45+ wanted << record.restores46+ }47+ // The saves table refers to each parent, so parents land first.48+ mut ordered := []SaveRecord{}49+ mut placed := map[string]bool{}50+ for ordered.len < fetched.saves.len {51+ before := ordered.len52+ for record in fetched.saves {53+ if record.id !in placed && (record.parent !in seen || record.parent in placed) {54+ ordered << record55+ placed[record.id] = true56+ }57+ }58+ if ordered.len == before {59+ return error('the remote history has a parent cycle')60+ }61+ }62+ fetched.saves = ordered63+ store.begin()!64+ write_fetched_records(store, fetched) or {65+ store.rollback()66+ return err67+ }68+ store.end()!69+ return ordered.len70+}71+72+/// Upload verified local copies of remote objects that are missing or damaged,73+/// and of missing save records with their objects.74+pub fn resend(store &storage.Store, mut backend remote.Backend, repo string, hashes []string, save_ids []string) !int {75+ // A record sent before this repo applies a remote strip would bring the76+ // stripped path back.77+ if unapplied_strips(store, mut backend, repo)!.len > 0 {78+ return error(strip_not_applied)79+ }80+ mut sent := 081+ for hash in hashes {82+ key := object_key(repo, hash)83+ existing := backend.get(key)!84+ if existing.found {85+ data := zlib.decompress(existing.body) or { []u8{} }86+ // Another machine may have put it right since the audit.87+ if storage.hash_hex(data) == hash {88+ continue89+ }90+ backend.delete(key)!91+ }92+ store.get_object(hash)!93+ if backend.put_if_absent(key, store.object_blob(hash)!)! {94+ sent++95+ }96+ }97+ for id in save_ids {98+ save := history.by_id(store, id) or { return error('save ${id} is missing here too') }99+ for file in history.files_of(store, id)! {100+ store.get_object(file.hash)!101+ if backend.put_if_absent(object_key(repo, file.hash), store.object_blob(file.hash)!)! {102+ sent++103+ }104+ }105+ if backend.put_if_absent(save_key(repo, id), encode_save(save_record(store, save)!).bytes())! {106+ sent++107+ }108+ }109+ return sent110+}111+112+/// Remove what recorded strips cover but a save still holds, without113+/// recording a new strip.114+pub fn reapply_strips(store &storage.Store) !int {115+ store.begin()!116+ mut removed := 0117+ for event in events.strip_events(store) or {118+ store.rollback()119+ return err120+ } {121+ removed += remove_path(store, event.path, event.save_id) or {122+ store.rollback()123+ return err124+ }125+ }126+ store.drop_unreferenced_objects() or {127+ store.rollback()128+ return err129+ }130+ store.end()!131+ return removed132+}src/storage/objects.v+46 −7
@@ -134,9 +134,16 @@134134 return before - after135135 }136136 137+/// An object Doctor found damaged or missing.138+pub struct ObjectProblem {139+pub:140+ hash string141+ detail string142+}143+137144 /// Verify every stored object's content hash and declared size.138-pub fn (s &Store) check_objects() ![]string {139- mut problems := []string{}145+pub fn (s &Store) check_objects() ![]ObjectProblem {146+ mut problems := []ObjectProblem{}140147 mut stmt := s.db.prepare('SELECT hash FROM objects ORDER BY hash')!141148 defer {142149 stmt.finalize()@@ -147,26 +154,58 @@147154 }148155 for hash in hashes {149156 data := s.get_object(hash) or {150- problems << 'object ${hash}: ${err.msg()}'157+ problems << ObjectProblem{hash, 'object ${hash}: ${err.msg()}'}151158 continue152159 }153160 if s.object_size(hash)! != data.len {154- problems << 'object ${hash}: stored size does not match ${data.len} bytes'161+ problems << ObjectProblem{hash, 'object ${hash}: stored size does not match ${data.len} bytes'}155162 }156163 }157164 return problems158165 }159166 160167 /// Find missing objects, including damage from writes with foreign keys disabled.161-pub fn (s &Store) check_save_files() ![]string {162- mut problems := []string{}168+pub fn (s &Store) check_save_files() ![]ObjectProblem {169+ mut problems := []ObjectProblem{}163170 mut stmt := s.db.prepare('SELECT DISTINCT f.save_id, f.hash FROM save_files f164171 LEFT JOIN objects o ON o.hash = f.hash WHERE o.hash IS NULL')!165172 defer {166173 stmt.finalize()167174 }168175 for stmt.step()! {169- problems << 'save ${stmt.text(0)} needs missing object ${stmt.text(1)}'176+ problems << ObjectProblem{stmt.text(1), 'save ${stmt.text(0)} needs missing object ${stmt.text(1)}'}170177 }171178 return problems179+}180+181+/// Store verified bytes for `hash`, replacing a damaged row in place so the182+/// saves that refer to it keep their rows.183+pub fn (s &Store) repair_object(hash string, data []u8) ! {184+ if hash_hex(data) != hash {185+ return error('the copy of object ${hash} does not match its hash')186+ }187+ packed := zlib.compress(data)!188+ mut stmt := s.db.prepare('INSERT INTO objects (hash, size, data) VALUES (?, ?, ?)189+ ON CONFLICT(hash) DO UPDATE SET size = excluded.size, data = excluded.data')!190+ defer {191+ stmt.finalize()192+ }193+ stmt.bind_text(1, hash)!194+ stmt.bind_int(2, i64(data.len))!195+ stmt.bind_blob(3, packed)!196+ stmt.step()!197+}198+199+/// Saved paths whose content is `hash`, where a working copy may still exist.200+pub fn (s &Store) paths_of_object(hash string) ![]string {201+ mut stmt := s.db.prepare('SELECT DISTINCT path FROM save_files WHERE hash = ?')!202+ defer {203+ stmt.finalize()204+ }205+ stmt.bind_text(1, hash)!206+ mut paths := []string{}207+ for stmt.step()! {208+ paths << stmt.text(0)209+ }210+ return paths172211 }tests/cli_test.v+17 −0
@@ -2106,6 +2106,17 @@21062106 assert broken.out.contains('"restore_unfinished":true')21072107 assert broken.out.contains('"checks":')21082108 assert broken.out.contains('"root":')2109+ assert broken.out.contains('"remedy":"run koh revert;')2110+ assert broken.out.contains('"repairs":["restore 2 damaged or missing objects from verified copies"')2111+ assert broken.out.contains('"repaired":[]')2112+ assert run(repo, 'doctor', '--json').code == 12113+ repaired := run(repo, 'doctor', '--json', '--yes')2114+ assert repaired.code == 0, repaired.out2115+ assert repaired.out.contains('"health":"healthy"')2116+ assert repaired.out.contains('"repaired":["restore 2 damaged or missing objects from verified copies","finish the interrupted restore')2117+ assert repaired.out.contains('"restore_unfinished":false')2118+ assert os.read_file(os.join_path(repo, 'app'))! == 'original'2119+ assert run(repo, 'view', '--json').out.contains('revert: discarded changes')21092120 assert run(repo, 'doctor', 'unexpected').code == 221102121 }21112122 @@ -2132,6 +2143,11 @@21322143 assert bad.code == 121332144 assert bad.out.contains('damaged remote object')21342145 assert bad.out.contains('"name":"SQLite integrity","status":"pass"')2146+ assert bad.out.contains('"repairs":["upload 1 object and 0 saves the remote lacks or holds damaged"]')2147+ mended := run_home(repo, home, 'doctor', '--json', '--yes')2148+ assert mended.code == 0, mended.out2149+ assert mended.out.contains('"repaired":["upload 1 object')2150+ assert os.read_file(os.join_path(bucket, name, 'objects', storage.hash_hex('original'.bytes())))! != 'corrupt'21352151 fast := run_home(repo, home, 'doctor', '--fast', '--json')21362152 assert fast.code == 021372153 assert fast.out.contains('--fast requested')@@ -2151,5 +2167,6 @@21512167 assert result.out.contains('"metadata_available":false')21522168 assert result.out.contains('"name":"database open","status":"fail"')21532169 assert result.out.contains('further checks could not run')2170+ assert result.out.contains('koh doctor --backup')21542171 assert result.err == ''21552172 }v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.8.4'3+ version: '3.8.5'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }