collab-runtime 0.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. tests/packaging/test_smoke_install.py +76 -0
src/logging_config.py ADDED
@@ -0,0 +1,259 @@
1
+ """Centralized logging helpers for the collab utilities.
2
+
3
+ This module configures a repository-scoped logs directory at `logs/` and provides an
4
+ idempotent helper to wire FileHandlers and a console handler without producing duplicate
5
+ handlers when called multiple times (useful for import-time setup in CLI tools and long-
6
+ running daemons).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import atexit
12
+ import logging
13
+ import os
14
+ import time
15
+ from logging.handlers import RotatingFileHandler
16
+ from typing import Optional
17
+
18
+ DEFAULT_FORMAT = "[%(asctime)s] %(levelname)s %(name)s: %(message)s"
19
+ DEFAULT_DATEFMT = "%Y-%m-%d %H:%M:%S"
20
+ DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024
21
+ DEFAULT_LOG_BACKUP_COUNT = 5
22
+ DEFAULT_LOG_RETENTION_DAYS = 5
23
+
24
+
25
+ def _read_clean_env_path(name: str) -> Optional[str]:
26
+ """Return a sanitized env path value, ignoring empty/comment-only values."""
27
+ raw = os.getenv(name)
28
+ if raw is None:
29
+ return None
30
+ cleaned = raw.strip()
31
+ if not cleaned:
32
+ return None
33
+ if "#" in cleaned:
34
+ cleaned = cleaned.split("#", 1)[0].strip()
35
+ if not cleaned or cleaned.startswith("#"):
36
+ return None
37
+ return cleaned
38
+
39
+
40
+ def _resolve_runtime_root() -> str:
41
+ """Resolve runtime root for the current project.
42
+
43
+ Preference order:
44
+ 1. COLLAB_HOME env override
45
+ 2. COLLAB_STATE_DIR env override (backwards compatibility/test isolation)
46
+ 3. Fallback to project root
47
+ """
48
+ home_override = _read_clean_env_path("COLLAB_HOME")
49
+ if home_override:
50
+ return os.path.abspath(home_override)
51
+
52
+ state_override = _read_clean_env_path("COLLAB_STATE_DIR")
53
+ if state_override:
54
+ return os.path.abspath(state_override)
55
+
56
+ project_root = _read_clean_env_path("COLLAB_PROJECT_ROOT") or os.path.abspath(
57
+ os.getcwd()
58
+ )
59
+ return project_root
60
+
61
+
62
+ def _ensure_dir(path: str) -> None:
63
+ try:
64
+ os.makedirs(path, exist_ok=True)
65
+ except Exception:
66
+ # Best-effort: if we cannot create a logs directory, don't raise here
67
+ # to avoid breaking CLI entrypoints. Upstream callers will still see
68
+ # console output.
69
+ pass
70
+
71
+
72
+ def _is_test_mode() -> bool:
73
+ return (
74
+ os.getenv("COLLAB_TEST_MODE") == "1"
75
+ or os.getenv("TESTING") == "1"
76
+ or "PYTEST_CURRENT_TEST" in os.environ
77
+ )
78
+
79
+
80
+ def _prune_old_log_files(
81
+ log_dir: str,
82
+ active_log_name: str,
83
+ *,
84
+ retention_days: int = DEFAULT_LOG_RETENTION_DAYS,
85
+ now: Optional[float] = None,
86
+ ) -> None:
87
+ """Delete expired rotated log files for the active log family.
88
+
89
+ The current active log file is never deleted here, even if its mtime is old.
90
+ Rotation limits file size; this pruning step bounds total on-disk lifetime.
91
+ """
92
+ if retention_days <= 0:
93
+ return
94
+
95
+ cutoff = (time.time() if now is None else now) - (retention_days * 24 * 60 * 60)
96
+ active_path = os.path.abspath(os.path.join(log_dir, active_log_name))
97
+ try:
98
+ entries = list(os.scandir(log_dir))
99
+ except OSError:
100
+ return
101
+
102
+ for entry in entries:
103
+ try:
104
+ if not entry.is_file():
105
+ continue
106
+ if not entry.name.startswith(active_log_name):
107
+ continue
108
+
109
+ entry_path = os.path.abspath(entry.path)
110
+ if entry_path == active_path:
111
+ continue
112
+
113
+ if entry.stat().st_mtime < cutoff:
114
+ os.remove(entry.path)
115
+ except OSError:
116
+ continue
117
+
118
+
119
+ def _resolve_log_dir(collab_dir: Optional[str]) -> str:
120
+ """Resolve the log directory for the current collab runtime.
121
+
122
+ Logs always live under ``<runtime_root>/logs/`` so they are persistent and
123
+ discoverable regardless of the test isolation environment. COLLAB_STATE_DIR
124
+ isolates *process-state* artifacts (PID files, lock files) but must not
125
+ redirect log files, because the temp dir it points to is cleaned up after each
126
+ test session, making the logs ephemeral and invisible to the developer.
127
+
128
+ File-handle safety for test mode is handled by:
129
+ - the ``atexit.register(close_collab_logging)`` call in this module, and
130
+ - the autouse ``_close_collab_logging_after_each_test`` fixture in
131
+ `tests/conftest.py`.
132
+ """
133
+ if collab_dir:
134
+ base = collab_dir
135
+ else:
136
+ base = _resolve_runtime_root()
137
+ return os.path.join(base, "logs")
138
+
139
+
140
+ def _close_collab_file_handlers() -> None:
141
+ """Close and detach all file handlers from the collab logger.
142
+
143
+ Windows keeps an exclusive handle on open log files, so tests and short-lived CLI
144
+ commands need an explicit close path when they finish.
145
+ """
146
+ collab_logger = logging.getLogger("collab")
147
+ for handler in list(collab_logger.handlers):
148
+ if getattr(handler, "baseFilename", None):
149
+ collab_logger.removeHandler(handler)
150
+ try:
151
+ handler.flush()
152
+ except Exception:
153
+ pass
154
+ try:
155
+ handler.close()
156
+ except Exception:
157
+ pass
158
+
159
+
160
+ def close_collab_logging() -> None:
161
+ """Release file-based collab logging handlers for the current process."""
162
+ _close_collab_file_handlers()
163
+
164
+
165
+ def setup_collab_logging(
166
+ collab_dir: Optional[str] = None, *, level: int = logging.INFO
167
+ ) -> logging.Logger:
168
+ """Configure logging for the collab tooling.
169
+
170
+ - collab_dir: runtime root directory. If omitted, the current project's
171
+ root directory is used, never `src/`.
172
+ - level: root logger level (defaults to INFO).
173
+
174
+ The function is idempotent: calling it multiple times will not attach
175
+ duplicate handlers.
176
+ Returns the root logger instance.
177
+ """
178
+ # Production logs remain in logs/. Test-mode logs are written to the
179
+ # isolated state dir so test daemons never lock files in the repo itself.
180
+ log_dir = _resolve_log_dir(collab_dir)
181
+ _ensure_dir(log_dir)
182
+
183
+ root = logging.getLogger()
184
+ root.setLevel(level)
185
+
186
+ # Simple console handler: keep when not present on the root logger
187
+ console_present = any(type(h).__name__ == "StreamHandler" for h in root.handlers)
188
+ if not console_present:
189
+ ch = logging.StreamHandler()
190
+ ch.setLevel(level)
191
+ ch.setFormatter(logging.Formatter(DEFAULT_FORMAT, DEFAULT_DATEFMT))
192
+ root.addHandler(ch)
193
+
194
+ # Configure a dedicated 'collab' logger for file-based logging. This
195
+ # prevents unrelated libraries or the application root logger from
196
+ # polluting the logs/ files. Handlers are attached to the
197
+ # 'collab' logger (and not to the root logger) so only logs emitted by
198
+ # 'collab' namespace (e.g. "src.lock_client", "collab.pycharm_watcher")
199
+ # are written to collab.log / test_collab.log.
200
+ collab_logger = logging.getLogger("collab")
201
+ collab_logger.setLevel(level)
202
+ # Allow records to propagate so test harnesses (caplog) and the root
203
+ # console handler can observe collab log records. FileHandlers are
204
+ # attached directly to the collab logger so only collab.* records are
205
+ # written to logs/; other libraries will not be recorded there.
206
+ collab_logger.propagate = True
207
+
208
+ # Single file handler: `collab.log` will contain both INFO and ERROR records.
209
+ # In test mode we use a distinct filename to avoid Windows file locks
210
+ # when the live daemon and tests run concurrently.
211
+ if os.getenv("COLLAB_TEST_MODE") == "1":
212
+ collab_name = "test_collab.log"
213
+ else:
214
+ collab_name = "collab.log"
215
+
216
+ collab_path = os.path.join(log_dir, collab_name)
217
+ _prune_old_log_files(log_dir, collab_name)
218
+
219
+ def _has_filehandler_for(logger_obj: logging.Logger, path: str) -> bool:
220
+ for h in getattr(logger_obj, "handlers", []):
221
+ if getattr(h, "baseFilename", None) == os.path.abspath(path):
222
+ return True
223
+ return False
224
+
225
+ # Remove stale file handlers so switching between test and production mode
226
+ # does not leave old file handles attached.
227
+ _stale = []
228
+ for h in list(collab_logger.handlers):
229
+ bf = getattr(h, "baseFilename", None)
230
+ if bf and bf != os.path.abspath(collab_path):
231
+ _stale.append(h)
232
+ for h in _stale:
233
+ collab_logger.removeHandler(h)
234
+ try:
235
+ h.close()
236
+ except Exception:
237
+ pass
238
+
239
+ if not _has_filehandler_for(collab_logger, collab_path):
240
+ try:
241
+ fh = RotatingFileHandler(
242
+ collab_path,
243
+ maxBytes=DEFAULT_LOG_MAX_BYTES,
244
+ backupCount=DEFAULT_LOG_BACKUP_COUNT,
245
+ encoding="utf-8",
246
+ )
247
+ # Use the provided root level so the single file captures INFO+ (and ERROR)
248
+ fh.setLevel(level)
249
+ fh.setFormatter(logging.Formatter(DEFAULT_FORMAT, DEFAULT_DATEFMT))
250
+ collab_logger.addHandler(fh)
251
+ except Exception:
252
+ # Best-effort: continue with console-only logging if file can't be opened
253
+ pass
254
+
255
+ # Return the root logger for convenience (callers typically ignore return)
256
+ return root
257
+
258
+
259
+ atexit.register(close_collab_logging)
src/main.py ADDED
@@ -0,0 +1,436 @@
1
+ """CLI orchestration layer for the collab package.
2
+
3
+ Separates command-line interface concerns from the core LockClient library, enabling
4
+ plugin/extension architecture for library consumers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import sys
13
+ import time
14
+ import traceback as _tb
15
+ from argparse import ArgumentParser
16
+ from contextlib import nullcontext
17
+
18
+ logger = logging.getLogger("collab.lock_client")
19
+
20
+
21
+ def _is_truthy_env(name: str, default: bool) -> bool:
22
+ """Return normalized boolean value for an env var."""
23
+ raw = os.getenv(name)
24
+ if raw is None:
25
+ return default
26
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
27
+
28
+
29
+ def _should_auto_start_watcher() -> bool:
30
+ """Decide whether CLI should auto-bootstrap the watcher daemon."""
31
+ # Keep pytest deterministic: command tests should not spawn background
32
+ # processes unless a test explicitly opts into that behavior.
33
+ if os.getenv("PYTEST_CURRENT_TEST"):
34
+ return False
35
+ return _is_truthy_env("COLLAB_AUTO_START_WATCHER", default=True)
36
+
37
+
38
+ def _ensure_watcher_running(client: object, command: str) -> bool:
39
+ """Best-effort watcher bootstrap for user-facing commands.
40
+
41
+ Returns True when this call started the daemon.
42
+ """
43
+ if command not in {"active", "status"}:
44
+ return False
45
+ if not _should_auto_start_watcher():
46
+ return False
47
+
48
+ try:
49
+ if bool(getattr(client, "daemon_status")()):
50
+ return False
51
+ except Exception as exc:
52
+ logger.debug("Failed to determine daemon status before auto-start: %s", exc)
53
+
54
+ try:
55
+ logger.info("Auto-starting watcher daemon for command: %s", command)
56
+ getattr(client, "daemon_start")()
57
+ return True
58
+ except Exception as exc:
59
+ logger.warning("Watcher auto-start failed for '%s': %s", command, exc)
60
+ return False
61
+
62
+
63
+ def _run_cli() -> None:
64
+ """CLI entry point for the lock client."""
65
+ # Force UTF-8 on Windows so Unicode symbols (✓, ❌, 🔒) render correctly.
66
+ # Mirrors the proven pattern from validate_code.py, format_code.py, run.py.
67
+ # errors="replace" ensures graceful fallback if the terminal truly cannot
68
+ # handle a character (e.g. bare cmd.exe with cp437).
69
+ for stream in (sys.stdout, sys.stderr):
70
+ reconfig = getattr(stream, "reconfigure", None)
71
+ if callable(reconfig):
72
+ try:
73
+ reconfig(encoding="utf-8", errors="replace")
74
+ except Exception as exc:
75
+ logger.debug("Failed to reconfigure stream encoding: %s", exc)
76
+
77
+ # Lazy imports to avoid circular dependency with lock_client
78
+ from .lock_client import _COLLAB_ROOT, LockClient, _quiet_console_loggers
79
+
80
+ # Wire up file-based logging early so all CLI output is captured.
81
+ from .logging_config import setup_collab_logging
82
+
83
+ setup_collab_logging(collab_dir=_COLLAB_ROOT)
84
+
85
+ parser = ArgumentParser(description="Collaborative File Lock Manager (Supabase)")
86
+ sub = parser.add_subparsers(dest="command")
87
+
88
+ # acquire
89
+ acq = sub.add_parser("acquire", help="Acquire a lock on a file")
90
+ acq.add_argument("file_path")
91
+ acq.add_argument("--reason", help="Reason for the lock")
92
+
93
+ # release
94
+ rel = sub.add_parser("release", help="Release a lock on a file")
95
+ rel.add_argument("file_path")
96
+
97
+ # active
98
+ sub.add_parser("active", help="List all active locks")
99
+
100
+ # status
101
+ st = sub.add_parser("status", help="Check lock status of a file")
102
+ st.add_argument("file_path")
103
+
104
+ # release-all
105
+ sub.add_parser("release-all", help="Release all locks held by you")
106
+
107
+ # force-release
108
+ fr = sub.add_parser(
109
+ "force-release",
110
+ help="Force release a lock (own locks only; admin can release any)",
111
+ )
112
+ fr.add_argument("file_path")
113
+ # force-release-all
114
+ sub.add_parser("force-release-all", help="Force release all locks (admin only)")
115
+
116
+ # acquire-batch
117
+ ab = sub.add_parser("acquire-batch", help="Acquire locks on multiple files")
118
+ ab.add_argument("file_paths", nargs="+")
119
+ ab.add_argument("--reason")
120
+
121
+ # release-batch
122
+ rb = sub.add_parser("release-batch", help="Release locks on multiple files")
123
+ rb.add_argument("file_paths", nargs="+")
124
+
125
+ # daemon-start
126
+ ds = sub.add_parser("daemon-start", help="Start the watcher daemon")
127
+ ds.add_argument("--interval", type=int, default=5, help="Poll interval (seconds)")
128
+ ds.add_argument(
129
+ "--timeout",
130
+ type=int,
131
+ default=0,
132
+ help="Idle timeout in minutes (0 = disabled)",
133
+ )
134
+ ds.add_argument("--open-dashboard", action="store_true")
135
+
136
+ # daemon-stop
137
+ sub.add_parser("daemon-stop", help="Stop the watcher daemon")
138
+
139
+ # daemon-status
140
+ sub.add_parser("daemon-status", help="Check watcher daemon status")
141
+
142
+ # cleanup - kill orphaned processes
143
+ sub.add_parser(
144
+ "cleanup", help="Kill all orphaned lock_client processes (preserves locks)"
145
+ )
146
+
147
+ # dashboard
148
+ sub.add_parser("dashboard", help="Open the collaborative dashboard")
149
+
150
+ # reconcile
151
+ sub.add_parser("reconcile", help="Sync local git status with Supabase")
152
+
153
+ # history
154
+ hp = sub.add_parser("history", help="Show lock history")
155
+ hp.add_argument("file_path", nargs="?")
156
+ hp.add_argument("--limit", type=int, default=20)
157
+ hp.add_argument(
158
+ "--json", action="store_true", dest="json_output", help="Output as raw JSON"
159
+ )
160
+
161
+ # history-prune
162
+ hpr = sub.add_parser("history-prune", help="Delete lock history older than N days")
163
+ hpr.add_argument("--days", type=int, default=30, help="Retention window in days")
164
+
165
+ # watch (internal, called by daemon-start)
166
+ wp = sub.add_parser("watch", help="Run watcher in foreground")
167
+ wp.add_argument("--interval", type=int, default=5)
168
+ wp.add_argument("--timeout", type=int, default=0)
169
+ wp.add_argument("--open-dashboard", action="store_true")
170
+ wp.add_argument(
171
+ "--daemon",
172
+ action="store_true",
173
+ help="Daemon mode: skip parent-PID liveness check",
174
+ )
175
+ wp.add_argument(
176
+ "--parent-pid", type=int, help="Tie watcher lifecycle to this parent PID"
177
+ )
178
+ wp.add_argument(
179
+ "--parent-name", type=str, help="Name of the parent process for better logging"
180
+ )
181
+ wp.add_argument(
182
+ "--parent-method",
183
+ type=str,
184
+ help=(
185
+ "Detection method used to infer parent PID (vscode_pid|"
186
+ "process_tree|simple_walk|pycharm_hosted|node_parent|"
187
+ "immediate_parent)"
188
+ ),
189
+ )
190
+ wp.add_argument(
191
+ "--heartbeat-file",
192
+ type=str,
193
+ help=(
194
+ "Optional path to a heartbeat file. If missing or stale, "
195
+ "watcher shuts down."
196
+ ),
197
+ )
198
+ wp.add_argument(
199
+ "--heartbeat-grace-seconds",
200
+ type=int,
201
+ default=10,
202
+ help="Heartbeat staleness threshold in seconds before shutdown.",
203
+ )
204
+ wp.add_argument(
205
+ "--pid-file",
206
+ type=str,
207
+ help="PID file path namespace for this watcher instance.",
208
+ )
209
+
210
+ args = parser.parse_args()
211
+ local_only = args.command in ("daemon-status", "daemon-stop")
212
+ client = LockClient(local_only=local_only)
213
+
214
+ # Keep CLI output clean by silencing noisy dependency logs (httpx, urllib3,
215
+ # postgrest, supabase) for user-facing commands that can hit network APIs.
216
+ quiet_commands = {
217
+ "acquire",
218
+ "release",
219
+ "active",
220
+ "status",
221
+ "release-all",
222
+ "force-release",
223
+ "force-release-all",
224
+ "acquire-batch",
225
+ "release-batch",
226
+ "daemon-stop",
227
+ "reconcile",
228
+ "history",
229
+ "history-prune",
230
+ }
231
+ quiet_ctx = (
232
+ _quiet_console_loggers() if args.command in quiet_commands else nullcontext()
233
+ )
234
+
235
+ with quiet_ctx:
236
+ if args.command == "acquire":
237
+ ok, msg = client.acquire(args.file_path, reason=args.reason)
238
+ if ok:
239
+ print(f"✓ Locked {args.file_path} (ID: {msg})")
240
+ else:
241
+ print(f"✗ Failed to lock {args.file_path}: {msg}")
242
+ sys.exit(0 if ok else 1)
243
+
244
+ elif args.command == "release":
245
+ ok, msg = client.release(args.file_path)
246
+ print(f"{'✓' if ok else '✗'} {msg}")
247
+
248
+ elif args.command == "active":
249
+ started_watcher = _ensure_watcher_running(client, "active")
250
+ if started_watcher:
251
+ try:
252
+ # Prime lock state immediately so `collab active` reflects
253
+ # current git changes right after watcher bootstrap.
254
+ client._reconcile()
255
+ except Exception as exc:
256
+ logger.debug("Auto-start reconcile failed: %s", exc)
257
+ locks = client.active()
258
+ if not locks:
259
+ print("No active locks.")
260
+ else:
261
+ for lk in locks:
262
+ print(
263
+ f" {lk.get('file_path')} — @{lk.get('developer_id')} "
264
+ f"(branch: {lk.get('branch_name', 'N/A')}, "
265
+ f"reason: {lk.get('reason', 'N/A')})"
266
+ )
267
+
268
+ elif args.command == "status":
269
+ _ensure_watcher_running(client, "status")
270
+ info = client.get_lock_status(args.file_path)
271
+ if info.get("is_locked"):
272
+ locked_by = info.get("locked_by")
273
+ acquired_at = info.get("acquired_at")
274
+ print(f"🔒 Locked by @{locked_by} since {acquired_at}")
275
+ else:
276
+ print("🔓 File is unlocked.")
277
+
278
+ elif args.command == "release-all":
279
+ count = client.release_all()
280
+ print(f"Released {count} lock(s).")
281
+
282
+ elif args.command == "force-release":
283
+ ok, msg = client.force_release(args.file_path)
284
+ print(f"{'✓' if ok else '✗'} {msg}")
285
+
286
+ elif args.command == "force-release-all":
287
+ if not client.is_admin:
288
+ print("✗ Permission denied: admin required to force-release all locks.")
289
+ sys.exit(1)
290
+ count = client.force_release_all()
291
+
292
+ # Minimal, user-facing output
293
+ print(f"✓ Force-released {count} lock(s).")
294
+ sys.exit(0)
295
+
296
+ elif args.command == "acquire-batch":
297
+ ok, failed, msg = client.acquire_multiple(
298
+ args.file_paths, reason=getattr(args, "reason", None)
299
+ )
300
+ if ok:
301
+ print(f"✓ Locked {len(args.file_paths)} file(s).")
302
+ else:
303
+ print(f"✗ {msg}. Failed: {failed}")
304
+ sys.exit(1)
305
+
306
+ elif args.command == "release-batch":
307
+ ok, count, msg = client.release_multiple(args.file_paths)
308
+ print(f"Released {count} lock(s).")
309
+
310
+ elif args.command == "daemon-start":
311
+ open_flag = getattr(args, "open_dashboard", False)
312
+ auto_env = os.getenv("AUTO_OPEN_DASHBOARD", "0").lower() in (
313
+ "1",
314
+ "true",
315
+ "yes",
316
+ )
317
+ client.daemon_start(
318
+ interval=getattr(args, "interval", 5),
319
+ timeout_mins=getattr(args, "timeout", 0),
320
+ open_dashboard=(open_flag or auto_env),
321
+ )
322
+
323
+ elif args.command == "daemon-stop":
324
+ # Suppress collab.* info logs from echoing to the terminal while
325
+ # performing the stop action (they will still be written to
326
+ # logs/collab.log).
327
+ client.daemon_stop()
328
+
329
+ elif args.command == "daemon-status":
330
+ running = client.daemon_status()
331
+ sys.exit(0 if running else 1)
332
+
333
+ elif args.command == "cleanup":
334
+ client.cleanup_orphaned_processes()
335
+
336
+ elif args.command == "dashboard":
337
+ client.dashboard()
338
+ print("Dashboard local server running. Press Ctrl+C to stop.")
339
+ try:
340
+ while True:
341
+ time.sleep(1)
342
+ except KeyboardInterrupt:
343
+ pass
344
+
345
+ elif args.command == "reconcile":
346
+ client._reconcile()
347
+
348
+ elif args.command == "history":
349
+ fp = getattr(args, "file_path", None)
350
+ rows = client.history(fp, limit=getattr(args, "limit", 20))
351
+ if getattr(args, "json_output", False):
352
+ print(json.dumps(rows, indent=2))
353
+ elif not rows:
354
+ if fp:
355
+ print(f"No history found for '{fp}'.")
356
+ print(" Tip: run 'collab history' (no file) to see all.")
357
+ else:
358
+ print("No lock history found.")
359
+ else:
360
+ # Check if results came from fallback (partial match)
361
+ if fp and rows and rows[0].get("file_path") != fp:
362
+ actual = rows[0].get("file_path", "")
363
+ print(
364
+ f" (No exact match for '{fp}' — "
365
+ f"showing partial matches for '{actual.rsplit('/', 1)[-1]}')\n"
366
+ )
367
+ for row in rows:
368
+ acquired = row.get("acquired_at", "?")[:19].replace("T", " ")
369
+ released = row.get("released_at", "?")[:19].replace("T", " ")
370
+ dev = row.get("developer_id", "?")
371
+ fpath = row.get("file_path", "?")
372
+ branch = row.get("branch_name", "")
373
+ outcome = row.get("outcome", "?")
374
+ print(
375
+ f" {fpath} @{dev} "
376
+ f"[{acquired} → {released}] "
377
+ f"branch:{branch} {outcome}"
378
+ )
379
+
380
+ elif args.command == "history-prune":
381
+ days = int(getattr(args, "days", 30))
382
+ ok, deleted, msg = client.prune_history(retention_days=days)
383
+ if ok:
384
+ print(
385
+ f"✓ Pruned {deleted} lock history row(s) "
386
+ f"older than {days} day(s)."
387
+ )
388
+ else:
389
+ print(f"✗ Failed to prune lock history: {msg}")
390
+ sys.exit(1)
391
+
392
+ elif args.command == "watch":
393
+ # Ensure watcher child process uses the explicit PID namespace passed by
394
+ # daemon-start. This prevents cross-instance status/stop interference.
395
+ if getattr(args, "pid_file", None):
396
+ from . import lock_client as _lc
397
+
398
+ _lc.PID_FILE = str(getattr(args, "pid_file"))
399
+ client.watch(
400
+ interval=getattr(args, "interval", 5),
401
+ timeout_mins=getattr(args, "timeout", 0),
402
+ open_dashboard=getattr(args, "open_dashboard", False),
403
+ daemon_mode=getattr(args, "daemon", False),
404
+ parent_pid=getattr(args, "parent_pid", None),
405
+ parent_name=getattr(args, "parent_name", None),
406
+ parent_method=getattr(args, "parent_method", None),
407
+ heartbeat_file=getattr(args, "heartbeat_file", None),
408
+ heartbeat_grace_seconds=getattr(args, "heartbeat_grace_seconds", 10),
409
+ )
410
+
411
+ else:
412
+ parser.print_help()
413
+
414
+
415
+ def main() -> None:
416
+ """Entry point for the collab CLI.
417
+
418
+ Orchestrates exception handling and error logging for the command-line interface.
419
+ Can be called from run.py, collab CLI, or package consumers that want to invoke the
420
+ CLI programmatically.
421
+ """
422
+ try:
423
+ _run_cli()
424
+ except Exception as exc:
425
+ tb_str = _tb.format_exc()
426
+ # Log to the standard logs/ directory via the structured logger.
427
+ logger.error("Unhandled exception: %s\n%s", exc, tb_str)
428
+ print(
429
+ "FATAL: lock_client crashed — see logs/collab.log",
430
+ file=sys.stderr,
431
+ )
432
+ sys.exit(1)
433
+
434
+
435
+ if __name__ == "__main__":
436
+ main()
File without changes
File without changes