oc_chat
Loading...
Searching...
No Matches
doctor.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/doctor.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_operator_tools_and_guards.py"
6"""
7Operator doctor checks for the orchestration registry.
8
9Purpose
10-------
11Provide the health checks and small repairs owned by M11.1c: local file
12permissions, stray temp-file cleanup, expired-lock sweep auditing, and
13credential fingerprint consistency.
14
15Responsibilities
16----------------
17- Inspect and optionally repair `.option_c` and `agent_secrets.json` modes.
18- Remove stray `.tmp-*` files below `.option_c/orchestration`.
19- Sweep expired lock rows and emit a `doctor.repair` audit operation.
20- Compare on-disk credential fingerprints with `agent_credentials`.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.MAIN
25Levels:
26 L2 - lifecycle events
27 L3 - semantic details
28 L4 - deep tracing
29
30Contracts
31---------
32- Audit rows go through `Registry.execute_domain_command`.
33- Secret values are never returned in structured output.
34- Windows permission inspection is a graceful v0.2.x skip.
35
36Dependencies
37------------
38- oc_diagnostics (structured debug logging via _dbg)
39- oc_chat.registry.api (Registry facade)
40- oc_chat.registry.backends.sqlite (SQLite backend)
41- oc_chat.registry.domain (plan_reimport for the trust-local snapshot repair)
42- oc_chat.registry.render (render_all for snapshot drift detection and trust-registry repair)
43- oc_chat.registry.render._common (RenderResult and plan_for_worktree lookup)
44"""
45from __future__ import annotations
46
47from dataclasses import dataclass
48import base64
49import hashlib
50import json
51import os
52from pathlib import Path
53import stat
54from typing import Mapping
55
56from oc_diagnostics import _dbg
57
58from oc_chat.registry.api import Registry
59from oc_chat.registry.backends.sqlite import SQLiteBackend
60from oc_chat.registry.domain import plan_reimport
61from oc_chat.registry.render import render_all
62from oc_chat.registry.render._common import RenderResult, plan_for_worktree
63
64
65_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
66
67OPTION_C_DIR = ".option_c"
68ORCHESTRATION_DIR = "orchestration"
69# OCT-LINT: disable=no_hardcoded_secrets reason="Secret-related names are local file and schema fields, not secret values"
70SECRETS_FILENAME = "agent_secrets.json"
71EXPECTED_OPTION_C_MODE = 0o700
72EXPECTED_SECRETS_MODE = 0o600
73
74
75@dataclass(frozen=True)
77 """Runtime flags for `doctor`."""
78
79 fix_perms: bool = False
80 skip_perms: bool = False
81 sweep_temps: bool = True
82 lock_sweep: bool = True
83 credential_check: bool = True
84 resolve_snapshot_conflict: bool = False
85 yes_trust_registry: bool = False
86 yes_trust_local: bool = False
87
88
90 registry: Registry,
91 backend: SQLiteBackend,
92 workspace_root: Path,
93 options: DoctorOptions,
94) -> dict[str, object]:
95 """Run all enabled doctor checks and return a JSON-safe report."""
96
97 option_c = workspace_root / OPTION_C_DIR
98 permissions = check_permissions(
99 option_c,
100 fix=options.fix_perms,
101 skip=options.skip_perms,
102 )
103 temp_files = (
104 sweep_stray_temp_files(option_c / ORCHESTRATION_DIR)
105 if options.sweep_temps
106 else _skipped("stray temp-file sweep skipped")
107 )
108 locks = (
109 sweep_expired_locks(registry, backend)
110 if options.lock_sweep
111 else _skipped("lock-expiry sweep skipped")
112 )
113 credentials = (
114 check_credential_consistency(backend, option_c / SECRETS_FILENAME)
115 if options.credential_check
116 else _skipped("credential consistency check skipped")
117 )
118 round_state = check_round_state(backend)
119 sections = [permissions, temp_files, locks, credentials, round_state]
120 report: dict[str, object] = {
121 "ok": True,
122 "workspace_root": str(workspace_root),
123 "registry_path": str(backend.path),
124 "permissions": permissions,
125 "temp_files": temp_files,
126 "locks": locks,
127 "credentials": credentials,
128 "round_state": round_state,
129 }
130 auth_events = auth_event_summary(backend)
131 if auth_events["failures"]:
132 report["auth_events"] = auth_events
133 if options.resolve_snapshot_conflict:
134 snapshots = check_snapshot_conflicts(
135 registry,
136 backend,
137 workspace_root,
138 options,
139 )
140 sections.append(snapshots)
141 report["snapshot_conflicts"] = snapshots
142 warnings = _warnings_from(*sections)
143 report["ok"] = not warnings
144 report["warnings"] = warnings
145 return report
146
147
148def check_permissions(option_c: Path, *, fix: bool, skip: bool) -> dict[str, object]:
149 """Check POSIX modes for `.option_c` and `agent_secrets.json`."""
150
151 if skip:
152 return _skipped("perms check skipped by flag")
153 if os.name == "nt":
154 return _skipped("perms check skipped on this platform")
155 targets = [
156 (option_c, EXPECTED_OPTION_C_MODE),
157 (option_c / SECRETS_FILENAME, EXPECTED_SECRETS_MODE),
158 ]
159 checked: list[dict[str, object]] = []
160 fixed: list[str] = []
161 warnings: list[str] = []
162 for path, expected_mode in targets:
163 if not path.exists():
164 warnings.append(f"{path} is missing")
165 continue
166 actual_mode = stat.S_IMODE(path.stat().st_mode)
167 checked.append(
168 {
169 "path": str(path),
170 "actual": oct(actual_mode),
171 "expected": oct(expected_mode),
172 }
173 )
174 if actual_mode == expected_mode:
175 continue
176 message = (
177 f"{path} mode is {oct(actual_mode)}; expected {oct(expected_mode)}"
178 )
179 if fix:
180 path.chmod(expected_mode)
181 fixed.append(str(path))
182 else:
183 warnings.append(message)
184 status = "ok"
185 if warnings:
186 status = "warn"
187 elif fixed:
188 status = "fixed"
189 return {
190 "status": status,
191 "checked": checked,
192 "fixed": fixed,
193 "warnings": warnings,
194 "messages": [],
195 }
196
197
198def sweep_stray_temp_files(orchestration_dir: Path) -> dict[str, object]:
199 """Remove stray `.tmp-*` files below the orchestration directory."""
200
201 removed: list[str] = []
202 warnings: list[str] = []
203 if not orchestration_dir.exists():
204 return {
205 "status": "ok",
206 "removed": removed,
207 "warnings": warnings,
208 "messages": [],
209 }
210 for path in sorted(orchestration_dir.rglob(".tmp-*")):
211 if not path.is_file() and not path.is_symlink():
212 warnings.append(f"skipped non-file temp path: {path}")
213 continue
214 try:
215 path.unlink()
216 removed.append(str(path))
217 except OSError as exc:
218 warnings.append(f"failed to remove {path}: {exc}")
219 return {
220 "status": "warn" if warnings else "ok",
221 "removed": removed,
222 "warnings": warnings,
223 "messages": [],
224 }
225
226
228 registry: Registry,
229 backend: SQLiteBackend,
230) -> dict[str, object]:
231 """Sweep expired locks and audit the repair when rows were removed."""
232
233 swept = backend.startup_sweep_with_names()
234 result: dict[str, object] = {
235 "status": "ok",
236 "swept": swept,
237 "count": len(swept),
238 "audit_operation_id": None,
239 "warnings": [],
240 "messages": [],
241 }
242 if not swept:
243 return result
244 operator_slot = backend.active_operator_slot()
245 if operator_slot is None:
246 result["status"] = "warn"
247 result["warnings"] = ["no active operator slot available for doctor audit"]
248 return result
249 row = registry.execute_domain_command(
250 "doctor.repair",
251 operator_slot,
252 {
253 "version": 1,
254 "repair_kind": "lock_expiry_sweep",
255 "items_repaired": swept,
256 "items_warned": [],
257 },
258 actor_type="human_operator",
259 )
260 result["audit_operation_id"] = row.id
261 return result
262
263
265 backend: SQLiteBackend,
266 secrets_path: Path,
267) -> dict[str, object]:
268 """Compare non-revoked DB credential fingerprints with the secrets file."""
269
270 credentials = [
271 row for row in backend.credential_fingerprints() if row.get("revoked_at") is None
272 ]
273 if not secrets_path.is_file():
274 return {
275 "status": "missing",
276 "checked": 0,
277 "warnings": [f"{secrets_path} is missing"],
278 "messages": [],
279 }
280 try:
281 data = json.loads(secrets_path.read_text(encoding="utf-8"))
282 except (OSError, json.JSONDecodeError) as exc:
283 return {
284 "status": "unreadable",
285 "checked": 0,
286 "warnings": [f"{secrets_path} could not be read as JSON: {exc}"],
287 "messages": [],
288 }
289 warnings: list[str] = []
290 checked = 0
291 for credential in credentials:
292 checked += 1
293 slot = str(credential["slot"])
294 phase = str(credential["rotation_phase"])
295 block = _secret_block(data, slot, phase)
296 if block is None:
297 warnings.append(f"missing secret block for {slot}/{phase}")
298 continue
299 expected_key = str(credential["hmac_key_id"])
300 actual_key = block.get("hmac_key_id")
301 if isinstance(actual_key, str) and actual_key != expected_key:
302 warnings.append(f"hmac_key_id mismatch for {slot}/{phase}")
303 actual_fingerprint = _fingerprint_for_block(block)
304 if actual_fingerprint != credential["secret_fingerprint"]:
305 warnings.append(f"secret_fingerprint mismatch for {slot}/{phase}")
306 return {
307 "status": "warn" if warnings else "ok",
308 "checked": checked,
309 "warnings": warnings,
310 "messages": [],
311 }
312
313
314def check_round_state(backend: SQLiteBackend) -> dict[str, object]:
315 """Audit round/round_job invariants (read-only).
316
317 Flags the OI-324 invariant (a ``closed`` round must be ``clean``) and any
318 round-job left in ``failed`` status that needs an operator resume or abort.
319 """
320
321 func = "check_round_state"
322 warnings: list[str] = []
323 with backend.connect() as conn:
324 bad_close = conn.execute(
325 # OCT-LINT: disable=no_hardcoded_secrets reason="Secret-related names are local file and schema fields, not secret values"
326 "SELECT round_no, cleanup_status FROM rounds "
327 "WHERE status = 'closed' AND cleanup_status != 'clean'"
328 ).fetchall()
329 failed_jobs = conn.execute(
330 # OCT-LINT: disable=no_hardcoded_secrets reason="Secret-related names are local file and schema fields, not secret values"
331 "SELECT job_id, round_no, current_step FROM round_jobs "
332 "WHERE status = 'failed' ORDER BY round_no"
333 ).fetchall()
334 in_flight = int(
335 conn.execute(
336 # OCT-LINT: disable=no_hardcoded_secrets reason="Secret-related names are local file and schema fields, not secret values"
337 "SELECT COUNT(*) FROM round_jobs WHERE status IN ('running', 'paused')"
338 ).fetchone()[0]
339 )
340 for row in bad_close:
341 warnings.append(
342 f"round {row['round_no']} is closed but cleanup_status="
343 f"{row['cleanup_status']!r} (OI-324 invariant)"
344 )
345 for row in failed_jobs:
346 warnings.append(
347 f"round {row['round_no']} job {str(row['job_id'])[:8]} failed at "
348 f"{row['current_step'] or '-'}; resume or abort required"
349 )
350 _dbg("OC_CHAT.MAIN", func, f"warnings={len(warnings)} in_flight={in_flight}", 2)
351 return {
352 "status": "warn" if warnings else "ok",
353 "checked": len(bad_close) + len(failed_jobs),
354 "in_flight": in_flight,
355 "failed_jobs": len(failed_jobs),
356 "warnings": warnings,
357 "messages": [],
358 }
359
360
361def auth_event_summary(backend: SQLiteBackend) -> dict[str, object]:
362 """Return auth-event counts for doctor output."""
363
364 with backend.connect() as conn:
365 total = int(conn.execute("SELECT COUNT(*) FROM auth_events").fetchone()[0])
366 failures = int(
367 conn.execute(
368 # OCT-LINT: disable=no_hardcoded_secrets reason="Secret-related names are local file and schema fields, not secret values"
369 "SELECT COUNT(*) FROM auth_events WHERE auth_ok = 0"
370 ).fetchone()[0]
371 )
372 return {
373 "status": "warn" if failures else "ok",
374 "checked": total,
375 "failures": failures,
376 "warnings": [],
377 "messages": [f"failed_auth_events={failures}"] if failures else [],
378 }
379
380
382 registry: Registry,
383 backend: SQLiteBackend,
384 workspace_root: Path,
385 options: DoctorOptions,
386) -> dict[str, object]:
387 """Detect and optionally resolve rendered artifact drift."""
388
389 dry_report = render_all(registry, target_root=workspace_root, dry_run=True)
390 drifted = [result for result in dry_report.results if result.drift_detected]
391 if not drifted:
392 return {
393 "status": "ok",
394 "checked": len(dry_report.results),
395 "resolved": [],
396 "warnings": [],
397 "messages": [],
398 }
399 warnings = [f"snapshot drift: {result.artifact_path}" for result in drifted]
400 if not options.resolve_snapshot_conflict:
401 return {
402 "status": "warn",
403 "checked": len(dry_report.results),
404 "resolved": [],
405 "warnings": warnings,
406 "messages": [],
407 }
408 actor_slot = backend.active_operator_slot()
409 if actor_slot is None:
410 return {
411 "status": "warn",
412 "checked": len(dry_report.results),
413 "resolved": [],
414 "warnings": ["no active operator slot available for snapshot repair"],
415 "messages": [],
416 }
417 mode = _resolution_mode(options)
418 if mode == "skip":
419 return {
420 "status": "warn",
421 "checked": len(dry_report.results),
422 "resolved": [],
423 "warnings": warnings,
424 "messages": ["snapshot repair skipped"],
425 }
426 lock = registry.acquire_lock(
427 "legacy-compat-write",
428 actor_slot,
429 ttl_seconds=300,
430 metadata={"command": "doctor --resolve-snapshot-conflict"},
431 )
432 try:
433 if mode == "trust_registry":
434 render_report = render_all(registry, target_root=workspace_root)
435 resolved = [
436 _audit_snapshot_repair(registry, actor_slot, result, mode)
437 for result in render_report.results
438 if result.drift_detected
439 ]
440 else:
441 resolved = [
442 _trust_local(registry, actor_slot, result)
443 for result in drifted
444 ]
445 finally:
446 lock.release()
447 unresolved = [item for item in resolved if item.get("status") != "resolved"]
448 return {
449 "status": "fixed" if not unresolved else "warn",
450 "checked": len(dry_report.results),
451 "resolved": resolved,
452 "warnings": [] if not unresolved else [str(item["message"]) for item in unresolved],
453 "messages": [f"resolved {len(resolved) - len(unresolved)} snapshot conflict(s)"],
454 }
455
456
457def format_doctor_report(report: Mapping[str, object]) -> str:
458 """Return a compact human-readable doctor summary."""
459
460 lines = [
461 f"registry: {report['registry_path']}",
462 f"workspace: {report['workspace_root']}",
463 _section_line("permissions", report["permissions"]),
464 _section_line("temp files", report["temp_files"]),
465 _section_line("locks", report["locks"]),
466 _section_line("credentials", report["credentials"]),
467 _section_line("round state", report["round_state"]),
468 ]
469 if "auth_events" in report:
470 lines.append(_section_line("auth events", report["auth_events"]))
471 if "snapshot_conflicts" in report:
472 lines.append(_section_line("snapshot conflicts", report["snapshot_conflicts"]))
473 warnings = report.get("warnings", [])
474 if isinstance(warnings, list) and warnings:
475 lines.append("warnings:")
476 lines.extend(f" - {warning}" for warning in warnings)
477 else:
478 lines.append("doctor ok")
479 return "\n".join(lines)
480
481
482def _section_line(label: str, section: object) -> str:
483 if not isinstance(section, Mapping):
484 return f"{label}: unknown"
485 status = section.get("status", "unknown")
486 details: list[str] = []
487 if "removed" in section:
488 details.append(f"removed={len(section.get('removed', []))}")
489 if "count" in section:
490 details.append(f"swept={section.get('count')}")
491 if "checked" in section:
492 details.append(f"checked={section.get('checked')}")
493 suffix = "" if not details else f" ({', '.join(details)})"
494 messages = section.get("messages", [])
495 message = ""
496 if isinstance(messages, list) and messages:
497 message = f" - {messages[0]}"
498 return f"{label}: {status}{suffix}{message}"
499
500
502 data: object,
503 slot: str,
504 phase: str,
505) -> Mapping[str, object] | None:
506 if not isinstance(data, Mapping):
507 return None
508 slot_block = data.get(slot)
509 if not isinstance(slot_block, Mapping):
510 return None
511 phase_block = slot_block.get(phase)
512 if not isinstance(phase_block, Mapping):
513 return None
514 return phase_block
515
516
517def _fingerprint_for_block(block: Mapping[str, object]) -> object:
518 secret_b64 = block.get("secret_b64")
519 if isinstance(secret_b64, str):
520 try:
521 raw = base64.b64decode(secret_b64.encode("ascii"), validate=True)
522 except (ValueError, UnicodeEncodeError):
523 raw = b""
524 if raw:
525 return hashlib.sha256(raw).hexdigest()
526 return block.get("secret_fingerprint")
527
528
529def _warnings_from(*sections: Mapping[str, object]) -> list[str]:
530 warnings: list[str] = []
531 for section in sections:
532 section_warnings = section.get("warnings", [])
533 if not isinstance(section_warnings, list):
534 continue
535 warnings.extend(str(warning) for warning in section_warnings)
536 return warnings
537
538
539def _skipped(message: str) -> dict[str, object]:
540 return {
541 "status": "skipped",
542 "warnings": [],
543 "messages": [message],
544 }
545
546
547def _resolution_mode(options: DoctorOptions) -> str:
548 if options.yes_trust_registry and options.yes_trust_local:
549 raise ValueError("choose only one of --yes-trust-registry/--yes-trust-local")
550 if options.yes_trust_registry:
551 return "trust_registry"
552 if options.yes_trust_local:
553 return "trust_local"
554 answer = input("Resolve snapshot drift? [r=trust-registry, l=trust-local, s=skip] ")
555 if answer.strip().lower() in {"r", "registry", "trust-registry"}:
556 return "trust_registry"
557 if answer.strip().lower() in {"l", "local", "trust-local"}:
558 return "trust_local"
559 return "skip"
560
561
563 registry: Registry,
564 actor_slot: str,
565 result: RenderResult,
566 action: str,
567) -> dict[str, object]:
568 row = registry.execute_domain_command(
569 "doctor.repair",
570 actor_slot,
571 {
572 "version": 1,
573 "repair_kind": "snapshot_conflict",
574 "action": action,
575 "artifact_kind": result.artifact_kind,
576 "artifact_path": str(result.artifact_path),
577 "content_sha256": result.content_sha256,
578 },
579 actor_type="human_operator",
580 command="doctor --resolve-snapshot-conflict",
581 )
582 return {
583 "status": "resolved",
584 "action": action,
585 "artifact_path": str(result.artifact_path),
586 "audit_operation_id": row.id,
587 }
588
589
591 registry: Registry,
592 actor_slot: str,
593 result: RenderResult,
594) -> dict[str, object]:
595 if result.artifact_kind != "plan_status":
596 return {
597 "status": "unsupported",
598 "action": "trust_local",
599 "artifact_path": str(result.artifact_path),
600 # OCT-LINT: disable=no_hardcoded_secrets reason="Secret-related names are local file and schema fields, not secret values"
601 "message": "trust-local is only available for .PLAN_STATUS.md",
602 }
603 row = plan_for_worktree(registry, result.artifact_path.parent)
604 if row is None:
605 return {
606 "status": "unsupported",
607 "action": "trust_local",
608 "artifact_path": str(result.artifact_path),
609 "message": "no registry plan is assigned to this worktree",
610 }
611 previous_sha = str(row.get("body_sha256") or "")
612 previous_observed = str(row.get("body_observed_at") or registry.clock.now_wall())
613 new_sha = hashlib.sha256(result.artifact_path.read_bytes()).hexdigest()
614 plan_row = plan_reimport(
615 registry,
616 plan_id=str(row["plan_id"]),
617 previous_body_sha256=previous_sha,
618 new_body_sha256=new_sha,
619 previous_body_observed_at=previous_observed,
620 new_body_observed_at=registry.clock.now_wall(),
621 actor_slot=actor_slot,
622 actor_type="human_operator",
623 command="doctor trust-local plan-status",
624 )
625 repair = _audit_snapshot_repair(registry, actor_slot, result, "trust_local")
626 repair["plan_reimport_operation_id"] = plan_row.id
627 return repair
dict[str, object] check_permissions(Path option_c, *, bool fix, bool skip)
Definition doctor.py:148
str _resolution_mode(DoctorOptions options)
Definition doctor.py:547
str _section_line(str label, object section)
Definition doctor.py:482
dict[str, object] auth_event_summary(SQLiteBackend backend)
Definition doctor.py:361
dict[str, object] check_round_state(SQLiteBackend backend)
Definition doctor.py:314
dict[str, object] sweep_stray_temp_files(Path orchestration_dir)
Definition doctor.py:198
dict[str, object] _audit_snapshot_repair(Registry registry, str actor_slot, RenderResult result, str action)
Definition doctor.py:567
Mapping[str, object]|None _secret_block(object data, str slot, str phase)
Definition doctor.py:505
dict[str, object] sweep_expired_locks(Registry registry, SQLiteBackend backend)
Definition doctor.py:230
object _fingerprint_for_block(Mapping[str, object] block)
Definition doctor.py:517
dict[str, object] check_credential_consistency(SQLiteBackend backend, Path secrets_path)
Definition doctor.py:267
dict[str, object] _trust_local(Registry registry, str actor_slot, RenderResult result)
Definition doctor.py:594
dict[str, object] check_snapshot_conflicts(Registry registry, SQLiteBackend backend, Path workspace_root, DoctorOptions options)
Definition doctor.py:386
dict[str, object] _skipped(str message)
Definition doctor.py:539
list[str] _warnings_from(*Mapping[str, object] sections)
Definition doctor.py:529
dict[str, object] run_doctor(Registry registry, SQLiteBackend backend, Path workspace_root, DoctorOptions options)
Definition doctor.py:94
str format_doctor_report(Mapping[str, object] report)
Definition doctor.py:457