26b642c1ae8a
verify repository health with comprehensive Doctor checks (3.8.0)
- time
- 2026-10-06 20:43 UTC · 6 days ago
- machine
- Junes-MacBook-Pro.local
- agent
- gpt-6
- session
- complete repo health checks
- parent
- 130bd1c5c7cf
↓ koh-26b642c1.tar.gzatlas at this save
files changed11 files
README.md+4 −1VERSION+1 −1shell/koh3.plugin.zsh+2 −2src/cli/cmd_doctor.v+149 −48src/cli/cmd_doctor_test.v+111 −1src/cli/doctor_checks.v+217 −0src/cli/doctor_remote.v+200 −0src/storage/doctor.v+77 −0src/storage/objects.v+7 −1tests/cli_test.v+68 −0v.mod+1 −1README.md+4 −1
@@ -174,7 +174,10 @@174174 koh doctor --backup /path/to/new-backup.koh175175 ```176176 177-`doctor` checks database integrity, object hashes, and saved file references.177+`doctor` checks the database, stored content, history, events, working files178+and configuration. It also verifies remote history and release files by default.179+Use `--offline` for a local check. The report names each check, its result,180+and any part it could not verify.178181 Its backup command writes a consistent copy of `.koh`. Use it instead of179182 copying the database while Koh is using it. Keep a live `.koh` out of cloud180183 folder sync services; use `offer` and `steal` between machines.shell/koh3.plugin.zsh+2 −2
@@ -135,7 +135,7 @@135135 'pluck:apply one save, or its inverse'136136 'peek:restore a save into a temp folder'137137 'init:create .koh here'138- 'doctor:integrity check and cleanup'138+ 'doctor:check repository health'139139 'ignore:ignore or unignore files and folders'140140 'strip:remove a path from history'141141 'build:build from koh.build'@@ -167,7 +167,7 @@167167 steal) _arguments '--yes' '--list' '--mine' '--theirs' '--json' ;;168168 ignore) _arguments '--global[edit ~/.kohignore for every repo]' '--yes[skip the question]' '--json' '*:path:_files' ;;169169 strip) _arguments '--yes[skip the yes/no question; paths still typed]' '--save[one save]:save:_koh3_ids' '*:path:_files' ;;170- doctor) _arguments '--yes[remove old peek folders]' '--backup[copy .koh to]:path:_files' ;;170+ doctor) _arguments '--offline[skip remote content checks]' '--json[print the report as JSON]' '--yes[remove old peek folders]' '--backup[copy .koh to]:path:_files' ;;171171 build) _arguments '*--platform[target platform]:platform:(macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64)' '--all' ;;172172 update) _arguments '--check' '--yes' '--json' ;;173173 migrate) _arguments '--lane[Koh 2 lane]:lane:(dev main)' '*--with[a lost version, from a backup]:file:_files' '--yes[skip the question]' '--json' ;;src/cli/cmd_doctor.v+149 −48
@@ -12,78 +12,133 @@1212 import time1313 import src.fmt1414 import src.history15-import src.snapshot1615 import src.storage1716 1817 pub fn cmd_doctor(raw []string) int {1918 args := parse('doctor', raw, Options{2019 values: ['backup']21- flags: ['json', 'yes']20+ flags: ['json', 'yes', 'offline']2221 }) or { return usage_error(err.msg()) }23- store := open_store() or { return refuse(args.json(), err.msg()) }24- defer {25- store.close()22+ if args.positional.len > 0 {23+ return usage_error('doctor takes no positional arguments')2624 }25+ store := open_store() or {26+ report := DoctorJson{27+ health: 'unhealthy'28+ root: storage.find_root(os.getwd()) or { os.getwd() }29+ problems: [err.msg()]30+ checks: [DoctorCheck{'database open', 'fail', err.msg()}]31+ scope: 'database could not be opened; further checks could not run'32+ }33+ if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }34+ return 135+ }36+ defer { store.close() }2737 mut report := DoctorJson{28- problems: []string{}38+ root: store.root39+ database: os.join_path(store.root, storage.file_name)40+ schema_version: storage.schema_version41+ checked_at: time.now().unix()42+ koh_version: version2943 }30-31- working('checking ${fmt.plural(int(store.object_count() or { 0 }), 'object')} and every save')32- report.problems << store.integrity_check() or { return refuse(args.json(), err.msg()) }33- report.problems << store.check_objects() or { return refuse(args.json(), err.msg()) }34- report.problems << store.check_save_files() or { return refuse(args.json(), err.msg()) }35- report.problems << unsafe_save_paths(store) or { return refuse(args.json(), err.msg()) }36-37- report.objects = int(store.object_count() or { 0 })38- report.saves = int(history.save_count(store) or { 0 })39- report.checkpoints = int(history.checkpoint_count(store) or { 0 })40- report.cached_files = int(store.cache_count() or { 0 })44+ working('checking database, objects and all stored history')45+ store.db.exec('BEGIN') or { return refuse(args.json(), err.msg()) }46+ report.check('SQLite integrity', store.integrity_check() or { [err.msg()] })47+ report.check('object hashes and sizes', store.check_objects() or { [err.msg()] })48+ report.check('save object references', store.check_save_files() or { [err.msg()] })49+ doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) }50+ store.end() or { report.check('read transaction', [err.msg()]) }51+ working('reading working files without cached hashes')52+ doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) }53+ doctor_build(store.root, mut report)4154 if args.flag('yes') {4255 report.peek_folders_removed = sweep_peek_folders()4356 }44- store.drop_cache() or { return refuse(args.json(), err.msg()) }45-4657 if path := args.value_or_none('backup') {47- if os.exists(path) {48- return refuse(args.json(), '${path} already exists')58+ mut backed_up := true59+ store.backup(path) or {60+ report.check('backup', [err.msg()])61+ backed_up = false4962 }50- store.backup(path) or { return refuse(args.json(), err.msg()) }51- report.backup = path63+ if backed_up {64+ report.backup = path65+ }5266 }67+ report.size_bytes = os.file_size(report.database)68+ report.wal_bytes = os.file_size('${report.database}-wal')69+ doctor_remote_config(mut report)70+ if !args.flag('offline') {71+ working('auditing remote history and releases (read-only)')72+ doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }73+ } else {74+ report.checks << DoctorCheck{'remote audit', 'skipped', '--offline requested; remote content not checked'}75+ }76+ report.scope = 'repository data and configuration; application tests, backup recovery and remote write permissions are not verified'77+ report.health = if report.problems.len > 0 {78+ 'unhealthy'79+ } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }80+ if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }81+ return if report.problems.len > 0 { 1 } else { 0 }82+}5383 54- if args.json() {55- emit_json(json2.encode(report))84+fn (mut report DoctorJson) check(name string, problems []string) {85+ report.checks << DoctorCheck{name, if problems.len == 0 { 'pass' } else { 'fail' }, if problems.len == 0 {86+ 'verified'5687 } else {57- if report.problems.len == 0 {58- ok('${store.root} is sound')88+ problems.join('; ')89+ }}90+ for problem in problems {91+ if problem !in report.problems { report.problems << problem }92+ }93+}94+95+fn print_doctor(report DoctorJson) {96+ if report.problems.len == 0 {97+ ok('${report.root} is sound · local health: ${report.health}')98+ } else {99+ warn('${report.root} has ${fmt.plural(report.problems.len, 'problem')}')100+ }101+ note('database: ${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')102+ if report.metadata_available {103+ if report.head.id != '' {104+ note('head [${report.head.id}] ${report.head.message.all_before('\n')} · ${fmt.stamp(report.head.created_at)}')105+ note('${report.head_files} files at head · agent: ${report.head.agent} · session: ${report.head.session}')59106 } else {60- for problem in report.problems {61- warn(problem)62- }107+ note('head: ${if report.saves == 0 { 'no saves yet' } else { report.head_id }}')63108 }64- note('${fmt.plural(report.objects, 'object')} · ${fmt.plural(report.saves, 'save')} · ${fmt.plural(report.checkpoints, 'checkpoint')}')65- if report.backup != '' {66- ok('backed up to ${report.backup}')67- }109+ note('${report.saves} stored saves · ${report.line_saves} on line · ${report.off_line_saves} off line · ${report.checkpoints} checkpoints')110+ note('${report.objects} objects · ${fmt.human_bytes(report.object_bytes)} content · ${fmt.human_bytes(report.packed_bytes)} compressed')111+ note('${report.events} events · ${report.flagged_saves} flagged saves · ${report.file_entries} file entries')112+ } else {113+ note('repository metadata unavailable; see failed checks')68114 }115+ for check in report.checks {116+ println(' ${check.status}: ${check.name} · ${check.detail}')117+ }118+ for warning in report.warnings {119+ warn(warning)120+ }121+ if report.remote_target != '' { note('remote: ${report.remote_target}') }122+ if report.source != '' { note('read-only source: ${report.source}') }123+ if report.last_offered_head != '' {124+ note('last successful offer: [${report.last_offered_head}] (stored value)')125+ }126+ if report.project_version != '' {127+ note('project ${report.project_version} · ${report.build_platforms.join(', ')}')128+ }129+ if report.cache_cleared {130+ note('cleared ${report.cached_files} cached hashes; working files read again')131+ }132+ if report.peek_folders_removed > 0 {133+ note('removed ${report.peek_folders_removed} old peek folders')134+ }135+ if report.backup != '' { ok('backed up to ${report.backup}') }69136 if report.problems.len > 0 {70- return 1137+ note('keep .koh intact before recovering history; use a known-good backup or another verified copy')71138 }72- return 0139+ note('scope: ${report.scope}')73140 }74141 75-fn unsafe_save_paths(store &storage.Store) ![]string {76- mut problems := []string{}77- for save in history.line(store)! {78- for file in history.files_of(store, save.id)! {79- if !snapshot.legal_path(file.path) {80- problems << 'save [${save.id}] has unsafe path ${snapshot.escape_path(file.path)} — koh strip ${shell_quote(file.path)}'81- }82- }83- }84- return problems85-}86-87142 fn shell_quote(text string) string {88143 return "'${text.replace("'", "'\\''")}'"89144 }@@ -114,13 +169,59 @@114169 return modified < now - peek_age115170 }116171 172+struct DoctorCheck {173+ name string174+ status string175+ detail string176+}177+117178 struct DoctorJson {118179 mut:180+ health string181+ scope string182+ metadata_available bool183+ ignore_files map[string]int184+ checks []DoctorCheck185+ warnings []string119186 problems []string187+ root string188+ database string189+ schema_version int190+ checked_at i64191+ koh_version string192+ head_id string193+ head history.Save194+ head_files int120195 objects int121196 saves int122197 checkpoints int198+ line_saves int199+ off_line_saves int200+ events int201+ flagged_saves int202+ head_flagged bool203+ file_entries i64204+ size_bytes u64205+ wal_bytes u64206+ object_bytes i64207+ packed_bytes i64208+ unreferenced_objects i64209+ restore_unfinished bool210+ pending_strips []string211+ changes Changes212+ ignored_saved_files []string213+ build_platforms []string214+ project_version string215+ source string216+ remote_repo string217+ remote_target string218+ last_offered_head string219+ remote_head string220+ remote_objects int221+ remote_saves int222+ remote_releases int123223 cached_files int224+ cache_cleared bool124225 peek_folders_removed int125226 backup string126227 }src/cli/cmd_doctor_test.v+111 −1
@@ -1,10 +1,37 @@11 module cli22 33 import os4+import compress.zlib5+import crypto.sha2566+import json27+import src.exchange8+import src.release9+import src.remote10+import src.events411 import src.history512 import src.snapshot613 import src.storage714 15+fn test_doctor_checks_cycles_and_missing_heads_without_walking_forever() {16+ saves := [history.Save{ id: 'aaaaaaaaaaaa', parent: 'bbbbbbbbbbbb' },17+ history.Save{ id: 'bbbbbbbbbbbb', parent: 'aaaaaaaaaaaa' }]18+ assert doctor_history(saves, 'cccccccccccc').any(it.contains('head'))19+ assert doctor_history(saves, 'aaaaaaaaaaaa').any(it.contains('cycle'))20+ assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa', parent: 'cccccccccccc' }],21+ 'aaaaaaaaaaaa').any(it.contains('missing parent'))22+ assert doctor_history([]history.Save{}, '').len == 023+ assert doctor_history([history.Save{ id: 'aaaaaaaaaaaa' }], '').any(it.contains('no head'))24+}25+26+fn test_doctor_checks_off_line_paths_and_file_directory_collisions() {27+ mut report := DoctorJson{}28+ doctor_files('aaaaaaaaaaaa', [snapshot.FileEntry{ path: '../unsafe' },29+ snapshot.FileEntry{ path: 'a' }, snapshot.FileEntry{ path: 'a/b' }], []events.Event{},30+ mut report)31+ assert report.problems.any(it.contains('unsafe path'))32+ assert report.problems.any(it.contains('file and a directory'))33+}34+835 fn test_doctor_reports_unsafe_save_paths_with_a_quoted_strip_command() {936 dir := os.join_path(os.temp_dir(), 'koh3_doctor_paths_${os.getpid()}')1037 os.rmdir_all(dir) or {}@@ -26,7 +53,90 @@2653 }])!2754 history.set_head(store, 'badbadbadbad')!2855 store.end()!29- assert unsafe_save_paths(store)! == [56+ mut report := DoctorJson{}57+ doctor_files('badbadbadbad', history.files_of(store, 'badbadbadbad')!, []events.Event{}, mut report)58+ assert report.problems == [3059 "save [badbadbadbad] has unsafe path ../June's file — koh strip '../June'\\''s file'",3160 ]61+}62+63+struct DoctorReadOnly {64+mut:65+ items map[string][]u866+}67+68+fn (mut backend DoctorReadOnly) get(key string) !remote.Object {69+ if body := backend.items[key] {70+ return remote.Object{ found: true, body: body }71+ }72+ return remote.Object{}73+}74+75+fn (mut backend DoctorReadOnly) list(prefix string) ![]string {76+ return backend.items.keys().filter(it.starts_with('${prefix}/'))77+}78+79+fn (mut backend DoctorReadOnly) put_if_absent(key string, body []u8) !bool {80+ panic('Doctor attempted a remote write')81+}82+83+fn (mut backend DoctorReadOnly) put_if_match(key string, body []u8, tag string) !bool {84+ panic('Doctor attempted a remote write')85+}86+87+fn (mut backend DoctorReadOnly) delete(key string) ! {88+ panic('Doctor attempted a remote delete')89+}90+91+fn test_doctor_remote_audits_unreferenced_objects_and_release_checksums_without_writes() {92+ data := 'file'.bytes()93+ hash := storage.hash_hex(data)94+ mut backend := DoctorReadOnly{95+ items: {96+ 'repo/head': 'aaaaaaaaaaaa'.bytes()97+ 'repo/saves/aaaaaaaaaaaa': json2.encode(exchange.SaveRecord{98+ id: 'aaaaaaaaaaaa'99+ files: [exchange.FileRecord{ path: 'file', hash: hash }]100+ }).bytes()101+ 'repo/objects/${hash}': zlib.compress(data)!102+ 'repo/releases/latest': '1.0.0'.bytes()103+ 'repo/releases/1.0.0/release.json': json2.encode(release.Record{104+ repo: 'repo'105+ version: '1.0.0'106+ save: 'aaaaaaaaaaaa'107+ files: [release.File{108+ platform: 'linux-aarch64'109+ name: 'app'110+ size: data.len111+ blake3: hash112+ sha256: sha256.sum256(data).hex()113+ }]114+ }).bytes()115+ 'repo/releases/1.0.0/linux-aarch64/app': data116+ }117+ }118+ mut report := DoctorJson{ head_id: 'aaaaaaaaaaaa' }119+ doctor_remote_content(mut backend, 'repo', mut report)!120+ assert report.problems.len == 0121+ assert report.remote_saves == 1122+ assert report.remote_releases == 1123+ backend.items['repo/objects/${storage.hash_hex('unreferenced'.bytes())}'] = 'bad zlib'.bytes()124+ backend.items['repo/releases/1.0.0/linux-aarch64/app'] = 'corrupt'.bytes()125+ mut damaged := DoctorJson{}126+ doctor_remote_content(mut backend, 'repo', mut damaged)!127+ assert damaged.problems.any(it.contains('damaged remote object'))128+ assert damaged.problems.any(it.contains('checksum verification'))129+}130+131+fn test_doctor_reports_event_errors_strip_residue_and_flags() {132+ mut report := DoctorJson{}133+ saves := [history.Save{ id: 'aaaaaaaaaaaa' }]134+ event_list := [events.Event{ id: 'strip', kind: 'strip', path: 'private' },135+ events.Event{ id: 'flag', kind: 'flag', save_id: 'aaaaaaaaaaaa' },136+ events.Event{ id: 'bad', kind: 'unknown' }]137+ doctor_events(event_list, saves, mut report)138+ doctor_files(saves[0].id, [snapshot.FileEntry{ path: 'private/file' }], event_list, mut report)139+ assert report.flagged_saves == 1140+ assert report.problems.any(it.contains('unknown kind'))141+ assert report.problems.any(it.contains('stripped path'))32142 }src/cli/doctor_checks.v+217 −0
@@ -0,0 +1,217 @@1+//! Doctor's local history, event and working-tree checks.2+module cli3+4+import os5+import src.build6+import src.events7+import src.history8+import src.snapshot9+import src.storage10+11+fn doctor_history(saves []history.Save, head string) []string {12+ mut problems := []string{}13+ mut known := map[string]history.Save{}14+ for save in saves {15+ known[save.id] = save16+ }17+ if head == '' && saves.len > 0 { problems << 'history has saves but no head' }18+ if head != '' && head !in known { problems << 'head ${head} names a missing save' }19+ mut done := map[string]bool{}20+ for save in saves {21+ if save.parent != '' && save.parent !in known {22+ problems << 'save ${save.id} has missing parent ${save.parent}'23+ }24+ if save.restores != '' && save.restores !in known {25+ problems << 'save ${save.id} restores missing save ${save.restores}'26+ }27+ mut cursor := save.id28+ mut seen := map[string]bool{}29+ for cursor != '' && cursor in known && cursor !in done {30+ if cursor in seen {31+ problems << 'history has a parent cycle at ${cursor}'32+ break33+ }34+ seen[cursor] = true35+ cursor = known[cursor].parent36+ }37+ for id in seen.keys() {38+ done[id] = true39+ }40+ }41+ return problems42+}43+44+fn doctor_files(id string, files []snapshot.FileEntry, event_list []events.Event,45+ mut report DoctorJson) {46+ mut paths := map[string]bool{}47+ for file in files {48+ paths[file.path] = true49+ }50+ for file in files {51+ if !snapshot.legal_path(file.path) {52+ report.problems << 'save [${id}] has unsafe path ${snapshot.escape_path(file.path)} — koh strip ${shell_quote(file.path)}'53+ }54+ mut parent := os.dir(file.path)55+ for parent != '.' && parent != '' && parent != '/' {56+ if parent in paths {57+ report.problems << 'save ${id}: ${snapshot.escape_path(parent)} is both a file and a directory'58+ break59+ }60+ parent = os.dir(parent)61+ }62+ for event in event_list {63+ if event.kind == events.kind_strip && event.path != ''64+ && (event.save_id == '' || event.save_id == id)65+ && (file.path == event.path || file.path.starts_with('${event.path.trim_right('/')}/')) {66+ report.problems << 'save ${id} still contains stripped path ${snapshot.escape_path(file.path)}'67+ }68+ }69+ }70+}71+72+fn doctor_events(event_list []events.Event, saves []history.Save, mut report DoctorJson) {73+ ids := saves.map(it.id)74+ mut flags := map[string]events.Event{}75+ for event in event_list {76+ if event.created_at < 0 { report.problems << 'event ${event.id} has a negative timestamp' }77+ if event.kind !in [events.kind_flag, events.kind_clear, events.kind_strip] {78+ report.problems << 'event ${event.id} has unknown kind ${event.kind}'79+ } else if event.kind != events.kind_strip && event.save_id == '' {80+ report.problems << 'event ${event.id} has no save target'81+ } else if event.kind == events.kind_strip && event.path == '' {82+ report.problems << 'strip event ${event.id} has no path'83+ }84+ // Sync may retain an event for an off-line save this receiver never fetched.85+ if event.save_id != '' && event.save_id !in ids {86+ report.warnings << 'event ${event.id} targets unavailable save ${event.save_id}'87+ }88+ if event.kind in [events.kind_flag, events.kind_clear] {89+ old := flags[event.save_id] or { events.Event{} }90+ if event.created_at > old.created_at || (event.created_at == old.created_at && event.id > old.id) {91+ flags[event.save_id] = event92+ }93+ }94+ }95+ report.events = event_list.len96+ report.flagged_saves = flags.values().filter(it.kind == events.kind_flag).len97+ if head_flag := flags[report.head_id] {98+ report.head_flagged = head_flag.kind == events.kind_flag99+ if report.head_flagged { report.warnings << 'head is flagged: ${head_flag.reason}' }100+ }101+}102+103+fn doctor_local(store &storage.Store, mut report DoctorJson) ! {104+ stats := store.doctor_stats()!105+ report.metadata_available = true106+ report.objects = int(stats.objects)107+ report.saves = int(stats.saves)108+ report.checkpoints = int(stats.checkpoints)109+ report.cached_files = int(stats.cached_files)110+ report.file_entries = stats.file_entries111+ report.object_bytes = stats.object_bytes112+ report.packed_bytes = stats.packed_bytes113+ report.unreferenced_objects = stats.unused114+ report.source = stats.state['source']115+ report.remote_repo = stats.state['remote_repo']116+ report.last_offered_head = stats.state['remote_head']117+ report.head_id = stats.state[storage.head_key]118+ report.restore_unfinished = 'restoring' in stats.state119+ report.pending_strips = stats.pending120+ report.check('references and metadata', stats.problems)121+ saves := history.all_saves(store)!122+ report.check('history links and cycles', doctor_history(saves, report.head_id))123+ mut known := map[string]history.Save{}124+ for save in saves {125+ known[save.id] = save126+ }127+ mut seen := map[string]bool{}128+ mut cursor := report.head_id129+ for cursor != '' && cursor in known && cursor !in seen {130+ seen[cursor] = true131+ cursor = known[cursor].parent132+ }133+ report.line_saves = seen.len134+ report.off_line_saves = saves.len - seen.len135+ if head := known[report.head_id] {136+ report.head = head137+ report.head_files = history.files_of(store, head.id)!.len138+ }139+ event_list := events.all(store)!140+ before := report.problems.len141+ doctor_events(event_list, saves, mut report)142+ for save in saves {143+ doctor_files(save.id, history.files_of(store, save.id)!, event_list, mut report)144+ if save.replayed_from != '' && save.replayed_from !in known {145+ report.warnings << 'save ${save.id} was replayed from unavailable save ${save.replayed_from}'146+ }147+ }148+ report.check('saved paths and events', report.problems[before..].clone())149+ if report.restore_unfinished {150+ report.check('restore state', [snapshot.restore_unfinished])151+ } else {152+ report.check('restore state', []string{})153+ }154+ for path in report.pending_strips {155+ report.warnings << 'remote strip unfinished for ${snapshot.escape_path(path)}; retry koh strip ${shell_quote(path)}'156+ }157+ if stats.unused > 0 {158+ report.warnings << '${stats.unused} unreferenced objects retained; Doctor does not remove history'159+ }160+}161+162+fn doctor_working_tree(store &storage.Store, mut report DoctorJson) ! {163+ store.drop_cache()!164+ report.cache_cleared = true165+ if !report.metadata_available || (report.saves > 0 && report.head.id == '') {166+ report.checks << DoctorCheck{'working tree', 'skipped', 'head is missing'}167+ return168+ }169+ for path in [os.join_path(store.root, snapshot.ignore_file), snapshot.global_ignore_path()] {170+ if !os.exists(path) {171+ continue172+ }173+ text := os.read_file(path) or {174+ report.check('ignore rules', [err.msg()])175+ continue176+ }177+ report.ignore_files[path] = text.split_into_lines().filter(it.trim_space() != '' && !it.trim_space().starts_with('#')).len178+ }179+ files := history.files_of(store, report.head_id)!180+ ig := snapshot.load(store.root)181+ report.ignored_saved_files = files.filter(ig.matches(it.path, false)).map(it.path)182+ report.changes = compare_with_head(store, report.head)!183+ mut problems := []string{}184+ snapshot.check_links(store.root, files) or { problems << err.msg() }185+ for path in report.changes.unsafe_paths {186+ problems << 'unsafe working name ${path}; this file cannot be saved'187+ }188+ 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'}190+ 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'192+ }193+}194+195+fn doctor_build(root string, mut report DoctorJson) {196+ if !os.exists(os.join_path(root, build.config_name)) {197+ report.checks << DoctorCheck{'build configuration', 'skipped', 'no koh.build'}198+ return199+ }200+ config := build.read_config(root) or {201+ report.check('build configuration', [err.msg()])202+ return203+ }204+ report.build_platforms = config.platforms205+ report.project_version = build.project_version(root) or {206+ report.check('build configuration', [err.msg()])207+ return208+ }209+ mut paths := map[string]bool{}210+ mut problems := []string{}211+ for platform in config.platforms {212+ path := build.output_path(config, platform, report.project_version)213+ if path in paths { problems << 'multiple platforms share build output ${path}' }214+ paths[path] = true215+ }216+ report.check('build configuration', problems)217+}src/cli/doctor_remote.v+200 −0
@@ -0,0 +1,200 @@1+//! Read-only remote inspection; Doctor never calls sync or the write probe.2+module cli3+4+import compress.zlib5+import crypto.sha2566+import json27+import os8+import src.events9+import src.exchange10+import src.history11+import src.machine12+import src.release13+import src.remote14+import src.snapshot15+import src.storage16+17+fn doctor_remote_config(mut report DoctorJson) {18+ if !machine.has_remote() {19+ report.checks << DoctorCheck{'machine remote', 'skipped', 'no machine remote configured'}20+ return21+ }22+ settings := machine.load_remote() or {23+ report.check('machine remote', [err.msg()])24+ return25+ }26+ report.remote_target = describe(settings)27+ if settings.kind == machine.kind_s3 {28+ machine.load_credentials() or {29+ report.check('machine remote', [err.msg()])30+ return31+ }32+ }33+ report.check('machine remote configuration', []string{})34+}35+36+fn doctor_remote(store &storage.Store, mut report DoctorJson) ! {37+ if report.source == '' && !machine.has_remote() {38+ report.checks << DoctorCheck{'remote audit', 'skipped', 'no remote or source configured'}39+ return40+ }41+ mut backend := if report.source != '' {42+ exchange.read_only(report.source)!43+ } else {44+ this_machine_backend()!45+ }46+ repo := if report.source != '' {47+ report.source.all_after_last('/')48+ } else if report.remote_repo != '' {49+ report.remote_repo50+ } else {51+ os.base(store.root).to_lower()52+ }53+ if !snapshot.legal_path(repo) || repo.contains('/') {54+ return error('unsafe remote repo name')55+ }56+ doctor_remote_content(mut backend, repo, mut report)!57+}58+59+fn doctor_remote_content(mut backend remote.Backend, repo string, mut report DoctorJson) ! {60+ format := backend.get(exchange.format_key(repo))!61+ if format.found && format.body.bytestr().trim_space() != 'koh-remote 1' {62+ return error('remote format is unsupported; update Koh')63+ }64+ head := backend.get(exchange.head_key(repo))!65+ report.remote_head = if head.found { head.body.bytestr().trim_space() } else { '' }66+ mut saves := []history.Save{}67+ mut files := map[string][]snapshot.FileEntry{}68+ mut hashes := map[string]bool{}69+ mut event_list := []events.Event{}70+ for key in backend.list('${repo}/saves')! {71+ if !snapshot.legal_path(key) || !key.starts_with('${repo}/saves/') {72+ return error('unsafe remote key ${key}')73+ }74+ working('checking remote saves · ${saves.len}')75+ object := backend.get(key)!76+ if !object.found {77+ return error('remote save disappeared: ${key}')78+ }79+ record := exchange.decode_save(object.body)!80+ if key != exchange.save_key(repo, record.id) {81+ return error('remote save ID disagrees with key ${key}')82+ }83+ saves << history.Save{84+ id: record.id85+ parent: record.parent86+ restores: record.restores87+ replayed_from: record.replayed_from88+ }89+ files[record.id] = record.files.map(snapshot.FileEntry{ path: it.path, hash: it.hash })90+ for file in record.files {91+ hashes[file.hash] = true92+ }93+ }94+ for key in backend.list('${repo}/events')! {95+ if !snapshot.legal_path(key) || !key.starts_with('${repo}/events/') {96+ return error('unsafe remote key ${key}')97+ }98+ working('checking remote events · ${event_list.len}')99+ object := backend.get(key)!100+ if !object.found {101+ return error('remote event disappeared: ${key}')102+ }103+ event := exchange.decode_event(object.body)!104+ if key != exchange.event_key(repo, event.id) {105+ return error('remote event ID disagrees with key ${key}')106+ }107+ event_list << event108+ }109+ for key in backend.list('${repo}/objects')! {110+ if !key.starts_with('${repo}/objects/') || key.all_after('${repo}/objects/').contains('/') {111+ return error('invalid remote object key ${key}')112+ }113+ hashes[key.all_after_last('/')] = true114+ }115+ mut remote_report := DoctorJson{ head_id: report.remote_head }116+ remote_report.problems = doctor_history(saves, report.remote_head)117+ doctor_events(event_list, saves, mut remote_report)118+ for save in saves {119+ doctor_files(save.id, files[save.id], event_list, mut remote_report)120+ }121+ for hash in hashes.keys() {122+ if hash.len != 64 || !hash.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) {123+ remote_report.problems << 'invalid remote object hash ${hash}'124+ continue125+ }126+ working('checking remote object ${hash[..12]}')127+ object := backend.get(exchange.object_key(repo, hash))!128+ if !object.found {129+ remote_report.problems << 'missing remote object ${hash}'130+ continue131+ }132+ data := zlib.decompress(object.body) or {133+ remote_report.problems << 'damaged remote object ${hash}'134+ continue135+ }136+ if storage.hash_hex(data) != hash {137+ remote_report.problems << 'remote object ${hash} fails Blake3 verification'138+ }139+ }140+ report.remote_objects = hashes.len141+ report.remote_saves = saves.len142+ report.check('remote history, events and objects', remote_report.problems)143+ report.warnings << remote_report.warnings.map('remote: ${it}')144+ remote_report.problems = []string{}145+ if mut backend is remote.Web {146+ report.checks << DoctorCheck{'remote releases', 'skipped', 'public source does not expose release storage listings'}147+ } else {148+ latest := backend.get(release.latest_key(repo))!149+ mut versions := []string{}150+ for key in backend.list('${repo}/releases')! {151+ if !snapshot.legal_path(key) || !key.starts_with('${repo}/releases/') {152+ return error('unsafe release key')153+ }154+ if !key.ends_with('/release.json') {155+ continue156+ }157+ object := backend.get(key)!158+ if !object.found {159+ return error('remote release disappeared: ${key}')160+ }161+ record := json2.decode[release.Record](object.body.bytestr())!162+ if record.format != 1 || record.repo != repo || key != release.release_key(repo, record.version)163+ || !snapshot.legal_path(record.version) || record.version.contains('/') {164+ return error('invalid remote release record ${key}')165+ }166+ if !saves.any(it.id == record.save) {167+ remote_report.problems << 'release ${record.version} refers to missing save ${record.save}'168+ }169+ if record.files.len == 0 { remote_report.problems << 'release ${record.version} has no files' }170+ for file in record.files {171+ if !snapshot.legal_path(file.name) || file.name.contains('/') || !snapshot.legal_path(file.platform)172+ || file.platform.contains('/') {173+ return error('unsafe release file in ${key}')174+ }175+ artifact := backend.get(release.file_key(repo, record.version, file.platform, file.name))!176+ if !artifact.found || artifact.body.len != file.size177+ || storage.hash_hex(artifact.body) != file.blake3178+ || sha256.sum256(artifact.body).hex() != file.sha256 {179+ remote_report.problems << 'release ${record.version} ${file.name} is missing or fails size/checksum verification'180+ }181+ }182+ versions << record.version183+ }184+ if latest.found && latest.body.bytestr().trim_space() !in versions {185+ remote_report.problems << 'remote latest release names a missing record'186+ }187+ report.remote_releases = versions.len188+ report.check('remote releases', remote_report.problems)189+ }190+ after := backend.get(exchange.head_key(repo))!191+ if after.found != head.found || after.body != head.body || after.tag != head.tag {192+ report.check('remote stability', ['remote head changed during audit; retry Doctor'])193+ }194+ if !head.found {195+ report.warnings << 'remote has no head for ${repo}; it may never have been offered or may have been renamed'196+ }197+ if report.remote_head != '' && report.remote_head != report.head_id {198+ report.warnings << 'local and remote heads differ; inspect koh steal before applying changes'199+ }200+}src/storage/doctor.v+77 −0
@@ -0,0 +1,77 @@1+//! Repository structure and storage measurements used by Doctor.2+module storage3+4+/// Storage counts and structural findings from one database snapshot.5+pub struct DoctorStats {6+pub mut:7+ objects i648+ saves i649+ checkpoints i6410+ cached_files i6411+ file_entries i6412+ object_bytes i6413+ packed_bytes i6414+ unused i6415+ pending []string16+ state map[string]string17+ problems []string18+}19+20+/// Inspect references, scalar storage invariants and machine-local state.21+pub fn (s &Store) doctor_stats() !DoctorStats {22+ mut out := DoctorStats{}23+ mut sizes := s.db.prepare('SELECT COUNT(*), COALESCE(SUM(size), 0),24+ COALESCE(SUM(length(data)), 0) FROM objects')!25+ defer { sizes.finalize() }26+ if sizes.step()! {27+ out.objects = sizes.int(0)28+ out.object_bytes = sizes.int(1)29+ out.packed_bytes = sizes.int(2)30+ }31+ mut counts := s.db.prepare('SELECT32+ (SELECT COUNT(*) FROM saves),33+ (SELECT COUNT(*) FROM saves WHERE is_checkpoint = 1),34+ (SELECT COUNT(*) FROM file_cache),35+ (SELECT COUNT(*) FROM save_files),36+ (SELECT COUNT(*) FROM objects WHERE hash NOT IN (SELECT hash FROM save_files))')!37+ defer { counts.finalize() }38+ if counts.step()! {39+ out.saves = counts.int(0)40+ out.checkpoints = counts.int(1)41+ out.cached_files = counts.int(2)42+ out.file_entries = counts.int(3)43+ out.unused = counts.int(4)44+ }45+ mut foreign := s.db.prepare('PRAGMA foreign_key_check')!46+ defer { foreign.finalize() }47+ for foreign.step()! {48+ out.problems << 'broken reference in ${foreign.text(0)} row ${foreign.int(1)} to ${foreign.text(2)}'49+ }50+ mut invalid := s.db.prepare("SELECT save_id, path FROM save_files51+ WHERE executable NOT IN (0, 1)52+ OR typeof(executable) != 'integer'53+ UNION ALL54+ SELECT id, 'invalid save metadata' FROM saves55+ WHERE is_checkpoint NOT IN (0, 1)56+ OR typeof(is_checkpoint) != 'integer'57+ OR id = ''58+ OR typeof(created_at) != 'integer'59+ OR created_at < 060+ OR typeof(size_bytes) != 'integer'61+ OR size_bytes < 0")!62+ defer { invalid.finalize() }63+ for invalid.step()! {64+ out.problems << 'invalid metadata in save ${invalid.text(0)}: ${invalid.text(1)}'65+ }66+ mut state := s.db.prepare('SELECT key, value FROM state')!67+ defer { state.finalize() }68+ for state.step()! {69+ key := state.text(0)70+ out.state[key] = state.text(1)71+ if key.starts_with('strip_pending:') {72+ out.pending << key.all_after('strip_pending:')73+ }74+ }75+ out.pending.sort()76+ return out77+}src/storage/objects.v+7 −1
@@ -147,7 +147,13 @@147147 hashes << stmt.text(0)148148 }149149 for hash in hashes {150- s.get_object(hash) or { problems << 'object ${hash}: ${err.msg()}' }150+ data := s.get_object(hash) or {151+ problems << 'object ${hash}: ${err.msg()}'152+ continue153+ }154+ if s.object_size(hash)! != data.len {155+ problems << 'object ${hash}: stored size does not match ${data.len} bytes'156+ }151157 }152158 return problems153159 }tests/cli_test.v+68 −0
@@ -2074,3 +2074,71 @@20742074 removed.finalize()20752075 checked.close()20762076 }2077+2078+fn test_doctor_reports_corruption_restores_and_dirty_work_in_one_document() {2079+ repo := temp_repo('doctor-health')2080+ init_repo(repo)2081+ write(repo, 'app', 'original')2082+ assert run(repo, 'save', 'baseline').code == 02083+ write(repo, 'app', 'unsaved work')2084+ ordinary := run(repo, 'doctor', '--json')2085+ assert ordinary.code == 02086+ assert ordinary.out.contains('"health":"healthy"')2087+ assert ordinary.out.contains('"modified":["app"]')2088+ assert ordinary.out.contains('"line_saves":1')2089+ assert ordinary.out.contains('"scope":')2090+ store := storage.open(repo)!2091+ store.db.exec('UPDATE objects SET size = size + 1')!2092+ store.set_state('restoring', 'yes')!2093+ store.close()2094+ broken := run(repo, 'doctor', '--json')2095+ assert broken.code == 12096+ assert broken.out.contains('"health":"unhealthy"')2097+ assert broken.out.contains('stored size does not match')2098+ assert broken.out.contains('"restore_unfinished":true')2099+ assert broken.out.contains('"checks":')2100+ assert broken.out.contains('"root":')2101+ assert run(repo, 'doctor', 'unexpected').code == 22102+}2103+2104+fn test_doctor_checks_the_remote_by_default_and_offline_explicitly_skips_it() {2105+ repo := temp_repo('doctor-remote')2106+ home := temp_repo('doctor-remote-home')2107+ bucket := temp_repo('doctor-remote-bucket')2108+ assert run_home(repo, home, 'remote', 'folder', bucket).code == 02109+ assert run_home(repo, home, 'init').code == 02110+ write(repo, 'app', 'original')2111+ assert run_home(repo, home, 'save', 'baseline').code == 02112+ before := run_home(repo, home, 'view', '--json').out2113+ healthy := run_home(repo, home, 'doctor', '--json')2114+ assert healthy.code == 02115+ assert healthy.out.contains('remote history, events and objects')2116+ assert healthy.out.contains('"remote_saves":1')2117+ assert run_home(repo, home, 'view', '--json').out == before2118+ store := storage.open(repo)!2119+ name := store.state('remote_repo') or { panic('missing remote repo') }2120+ store.close()2121+ os.write_file(os.join_path(bucket, name, 'objects', storage.hash_hex('original'.bytes())),2122+ 'corrupt')!2123+ bad := run_home(repo, home, 'doctor', '--json')2124+ assert bad.code == 12125+ assert bad.out.contains('damaged remote object')2126+ assert bad.out.contains('"name":"SQLite integrity","status":"pass"')2127+ offline := run_home(repo, home, 'doctor', '--offline', '--json')2128+ assert offline.code == 02129+ assert offline.out.contains('--offline requested')2130+ assert offline.out.contains('"status":"skipped"')2131+}2132+2133+fn test_doctor_returns_a_report_when_the_database_cannot_be_opened() {2134+ repo := temp_repo('doctor-unreadable')2135+ init_repo(repo)2136+ os.write_file(os.join_path(repo, '.koh'), 'not a SQLite database')!2137+ result := run(repo, 'doctor', '--json')2138+ assert result.code == 12139+ assert result.out.contains('"health":"unhealthy"')2140+ assert result.out.contains('"metadata_available":false')2141+ assert result.out.contains('"name":"database open","status":"fail"')2142+ assert result.out.contains('further checks could not run')2143+ assert result.err == ''2144+}v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.7.4'3+ version: '3.8.0'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }