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.
- collab/__init__.py +77 -0
- collab/__main__.py +11 -0
- collab_runtime-0.2.9.dist-info/METADATA +218 -0
- collab_runtime-0.2.9.dist-info/RECORD +82 -0
- collab_runtime-0.2.9.dist-info/WHEEL +5 -0
- collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
- collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
- collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
- scripts/cleanup.py +395 -0
- scripts/collab_git_hook.py +190 -0
- scripts/format_code.py +594 -0
- scripts/generate_tests.py +560 -0
- scripts/validate_code.py +1397 -0
- src/__init__.py +4 -0
- src/dashboard/index.html +1131 -0
- src/live_locks_watcher.py +1982 -0
- src/lock_client.py +4268 -0
- src/logging_config.py +259 -0
- src/main.py +436 -0
- tests/backend/__init__.py +0 -0
- tests/backend/functional/__init__.py +0 -0
- tests/backend/functional/test_package_imports.py +43 -0
- tests/backend/integration/__init__.py +0 -0
- tests/backend/integration/test_cli_contract_parity.py +220 -0
- tests/backend/performance/__init__.py +0 -0
- tests/backend/reliability/__init__.py +0 -0
- tests/backend/security/__init__.py +0 -0
- tests/backend/unit/live_locks_watcher/__init__.py +5 -0
- tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
- tests/backend/unit/live_locks_watcher/conftest.py +18 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
- tests/backend/unit/lock_client/__init__.py +1 -0
- tests/backend/unit/lock_client/_helpers.py +132 -0
- tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
- tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
- tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
- tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
- tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
- tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
- tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
- tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
- tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
- tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
- tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
- tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
- tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
- tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
- tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
- tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
- tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
- tests/backend/unit/scripts/__init__.py +1 -0
- tests/backend/unit/scripts/_helpers.py +42 -0
- tests/backend/unit/scripts/test_cleanup.py +285 -0
- tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
- tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
- tests/backend/unit/scripts/test_format_code.py +368 -0
- tests/backend/unit/scripts/test_format_code_ported.py +177 -0
- tests/backend/unit/scripts/test_generate_tests.py +305 -0
- tests/backend/unit/scripts/test_hook_templates.py +357 -0
- tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
- tests/backend/unit/scripts/test_validate_code.py +867 -0
- tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
- tests/backend/unit/test_entrypoints_main_run.py +83 -0
- tests/backend/unit/test_logging_config.py +529 -0
- tests/backend/unit/test_main_watch_pid_file.py +278 -0
- tests/conftest.py +167 -0
- tests/frontend/__init__.py +0 -0
- tests/frontend/jest/__init__.py +0 -0
- tests/frontend/playwright/__init__.py +0 -0
- 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
|