oc_chat
Loading...
Searching...
No Matches
sync.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/cli/sync.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_sync_cli.py"
6"""
7Operator CLI for registry artifact rendering.
8
9Purpose
10-------
11Expose `python -m oc_chat.registry sync` for re-rendering registry-backed
12filesystem artifacts.
13
14Responsibilities
15----------------
16- Resolve the registry and workspace root through standard discovery.
17- Acquire the compatibility write lock while rendering artifacts.
18- Emit one `sync.run` audit operation through the registry seam.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - sync command lifecycle events
25 L3 - artifact action summaries
26 L4 - argparse/dispatch tracing
27
28Contracts
29---------
30- All registry mutations go through `Registry.execute_domain_command`.
31- Drift warnings are reported to stderr before human output.
32
33Dependencies
34------------
35- oc_diagnostics (structured debug logging via _dbg)
36- oc_chat.registry.discovery (path resolution)
37- oc_chat.registry.render (artifact renderer)
38- oc_chat.registry (Registry seam for the write lock and the sync.run audit)
39- oc_chat.registry.backends.sqlite (SQLiteBackend over the discovered registry path)
40- oc_chat.registry.domain (plan_reimport for kept local plan-status files)
41- oc_chat.registry.render._common (RenderResult and plan_for_worktree lookup)
42"""
43from __future__ import annotations
44
45import argparse
46import hashlib
47import json
48import sys
49from pathlib import Path
50from typing import Mapping
51
52from oc_diagnostics import _dbg
53
54from oc_chat.registry import Registry
55from oc_chat.registry.backends.sqlite import SQLiteBackend
56from oc_chat.registry.discovery import discover_registry_location
57from oc_chat.registry.domain import plan_reimport
58from oc_chat.registry.render import ALL_ARTIFACTS, render_all
59from oc_chat.registry.render._common import RenderResult, plan_for_worktree
60
61
62_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
63
64LOCK_NAME = "legacy-compat-write"
65
66
67def add_sync_subcommand(subcommands: argparse._SubParsersAction) -> None:
68 """Attach the ``sync`` subcommand."""
69
70 parser = subcommands.add_parser(
71 "sync",
72 help="Render registry-backed filesystem artifacts.",
73 )
74 parser.add_argument(
75 "registry",
76 nargs="?",
77 help="Registry SQLite path. Defaults to the discovery algorithm.",
78 )
79 parser.add_argument(
80 "--prefer-local",
81 action="store_true",
82 help="Keep drifted local files instead of overwriting them.",
83 )
84 parser.add_argument(
85 "--dry-run",
86 action="store_true",
87 help="Report render actions without writing artifacts.",
88 )
89 parser.add_argument(
90 "--artifact",
91 action="append",
92 choices=sorted(ALL_ARTIFACTS),
93 help="Render only this artifact kind. May be passed multiple times.",
94 )
95 parser.add_argument("--actor-slot", help="Actor slot. Defaults to active operator.")
96 parser.add_argument("--json", action="store_true")
97 parser.set_defaults(handler=_cmd_sync)
98
99
100def _cmd_sync(args: argparse.Namespace) -> int:
101 location = discover_registry_location(registry_path=args.registry)
102 backend = SQLiteBackend(location.registry_path)
103 registry = Registry(backend)
104 actor_slot = args.actor_slot or backend.active_operator_slot()
105 if actor_slot is None:
106 print("no active operator slot found", file=sys.stderr)
107 return 1
108 lock = registry.acquire_lock(
109 LOCK_NAME,
110 actor_slot,
111 ttl_seconds=300,
112 metadata={"command": "sync"},
113 )
114 try:
115 report = render_all(
116 registry,
117 target_root=Path(location.workspace_root),
118 prefer_local=args.prefer_local,
119 dry_run=args.dry_run,
120 artifacts=args.artifact,
121 )
122 if args.prefer_local and not args.dry_run:
123 _reimport_kept_plan_statuses(registry, actor_slot, report.results)
124 _audit_sync(registry, actor_slot, report.to_dict())
125 finally:
126 lock.release()
127 for result in report.results:
128 if result.drift_detected:
129 print(f"drift detected: {result.artifact_path}", file=sys.stderr)
130 if (
131 args.prefer_local
132 and result.action_taken == "skipped_local_kept"
133 and result.artifact_kind != "plan_status"
134 ):
135 print(
136 f"prefer-local is unsupported for {result.artifact_kind}: "
137 f"{result.artifact_path}",
138 file=sys.stderr,
139 )
140 if args.json:
141 print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
142 else:
143 print(format_sync_report(report.to_dict()))
144 return 0 if report.ok else 1
145
146
147def format_sync_report(report: Mapping[str, object]) -> str:
148 """Return compact human output for sync results."""
149
150 lines = ["sync ok" if report.get("ok") else "sync failed"]
151 results = report.get("results", [])
152 if isinstance(results, list):
153 for result in results:
154 if not isinstance(result, Mapping):
155 continue
156 lines.append(
157 " "
158 + f"{result.get('artifact_kind')}: "
159 + f"{result.get('action_taken')} "
160 + f"{result.get('artifact_path')}"
161 )
162 return "\n".join(lines)
163
164
166 registry: Registry,
167 actor_slot: str,
168 report: Mapping[str, object],
169) -> None:
170 registry.execute_domain_command(
171 "sync.run",
172 actor_slot,
173 {
174 "version": 1,
175 "artifacts": report.get("results", []),
176 },
177 actor_type="human_operator",
178 command="sync",
179 )
180
181
183 registry: Registry,
184 actor_slot: str,
185 results: tuple[RenderResult, ...],
186) -> None:
187 for result in results:
188 if result.artifact_kind != "plan_status":
189 continue
190 if result.action_taken != "skipped_local_kept":
191 continue
192 row = plan_for_worktree(registry, result.artifact_path.parent)
193 if row is None:
194 continue
195 previous_sha = str(row.get("body_sha256") or "")
196 previous_observed = str(row.get("body_observed_at") or registry.clock.now_wall())
197 new_sha = hashlib.sha256(result.artifact_path.read_bytes()).hexdigest()
198 plan_reimport(
199 registry,
200 plan_id=str(row["plan_id"]),
201 previous_body_sha256=previous_sha,
202 new_body_sha256=new_sha,
203 previous_body_observed_at=previous_observed,
204 new_body_observed_at=registry.clock.now_wall(),
205 actor_slot=actor_slot,
206 actor_type="human_operator",
207 command="sync --prefer-local plan-status",
208 )
None _reimport_kept_plan_statuses(Registry registry, str actor_slot, tuple[RenderResult,...] results)
Definition sync.py:186
None add_sync_subcommand(argparse._SubParsersAction subcommands)
Definition sync.py:67
str format_sync_report(Mapping[str, object] report)
Definition sync.py:147
int _cmd_sync(argparse.Namespace args)
Definition sync.py:100
None _audit_sync(Registry registry, str actor_slot, Mapping[str, object] report)
Definition sync.py:169