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
11 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 −1
README.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.

view file

VERSION+1 −1
@@ -1 +1 @@1-3.8.41+3.8.5

view file

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 }

view file

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+}

view file

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 {

view file

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'

view file

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+}

view file

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+}

view file

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 }

view file

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 }

view file

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 }

view file