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
|
@@ -0,0 +1,1982 @@
|
|
|
1
|
+
"""Standalone lock watcher for PyCharm and other IDEs.
|
|
2
|
+
|
|
3
|
+
Monitors local git status and subscribes to Supabase Realtime for
|
|
4
|
+
collaborative file lock notifications. Uses plyer for cross-platform
|
|
5
|
+
desktop notifications.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python -m src.live_locks_watcher [--interval 5] [--timeout 0]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import atexit
|
|
14
|
+
import hashlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import signal
|
|
20
|
+
import socket
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import traceback
|
|
27
|
+
import webbrowser
|
|
28
|
+
from datetime import datetime, timedelta, timezone
|
|
29
|
+
from importlib import import_module
|
|
30
|
+
from typing import Any, Callable, Optional, Protocol, cast
|
|
31
|
+
|
|
32
|
+
from dotenv import load_dotenv
|
|
33
|
+
|
|
34
|
+
# NOTE: do NOT import collab-local modules before the runtime root and sys.path
|
|
35
|
+
# setup is complete. The import for `logging_config` is moved
|
|
36
|
+
# lower in this file (after sys.path.insert and load_dotenv) so that
|
|
37
|
+
# the local helper module can be resolved reliably when running from
|
|
38
|
+
# the project root or via IDE run configurations.
|
|
39
|
+
|
|
40
|
+
# Optional colored output (avoid try/except to reduce unreachable-branch lines)
|
|
41
|
+
_HAS_COLORAMA = False
|
|
42
|
+
try:
|
|
43
|
+
_colorama_spec = importlib.util.find_spec("colorama")
|
|
44
|
+
except Exception:
|
|
45
|
+
_colorama_spec = None
|
|
46
|
+
if _colorama_spec is not None:
|
|
47
|
+
colorama_mod = import_module("colorama")
|
|
48
|
+
Fore = getattr(colorama_mod, "Fore")
|
|
49
|
+
Style = getattr(colorama_mod, "Style")
|
|
50
|
+
_colorama_init = getattr(colorama_mod, "init", None)
|
|
51
|
+
if callable(_colorama_init):
|
|
52
|
+
try:
|
|
53
|
+
_colorama_init()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
_HAS_COLORAMA = True
|
|
57
|
+
|
|
58
|
+
# Runtime roots
|
|
59
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_clean_env_path(name: str) -> Optional[str]:
|
|
63
|
+
"""Return a sanitized path-like environment override."""
|
|
64
|
+
raw = os.getenv(name)
|
|
65
|
+
if raw is None:
|
|
66
|
+
return None
|
|
67
|
+
cleaned = raw.strip()
|
|
68
|
+
if not cleaned:
|
|
69
|
+
return None
|
|
70
|
+
if "#" in cleaned:
|
|
71
|
+
cleaned = cleaned.split("#", 1)[0].strip()
|
|
72
|
+
if not cleaned or cleaned.startswith("#"):
|
|
73
|
+
return None
|
|
74
|
+
return cleaned
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_project_root_override = _read_clean_env_path("COLLAB_PROJECT_ROOT")
|
|
78
|
+
_PROJECT_ROOT = os.path.abspath(_project_root_override or os.getcwd())
|
|
79
|
+
_runtime_base = _read_clean_env_path("COLLAB_HOME") or _read_clean_env_path(
|
|
80
|
+
"COLLAB_STATE_DIR"
|
|
81
|
+
)
|
|
82
|
+
if _runtime_base:
|
|
83
|
+
_COLLAB_ROOT = os.path.abspath(_runtime_base)
|
|
84
|
+
else:
|
|
85
|
+
_COLLAB_ROOT = _PROJECT_ROOT
|
|
86
|
+
_RESOURCE_ROOT = _THIS_DIR
|
|
87
|
+
os.makedirs(_COLLAB_ROOT, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
# Load environment before reading config variables
|
|
90
|
+
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
|
91
|
+
|
|
92
|
+
_setup_collab_logging_obj: Any = None
|
|
93
|
+
try:
|
|
94
|
+
from . import logging_config as _logging_config
|
|
95
|
+
|
|
96
|
+
_setup_collab_logging_obj = getattr(_logging_config, "setup_collab_logging", None)
|
|
97
|
+
except Exception:
|
|
98
|
+
_setup_collab_logging_obj = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def setup_collab_logging(collab_dir: str) -> None:
|
|
102
|
+
"""Dynamically routes logging setup or falls back to basicConfig.
|
|
103
|
+
|
|
104
|
+
This proxy func resolves static analyzer type-hinting natively without relying on
|
|
105
|
+
TYPE_CHECKING imports or triggering F811 redefinition lints.
|
|
106
|
+
"""
|
|
107
|
+
if _setup_collab_logging_obj is not None:
|
|
108
|
+
_setup_collab_logging_obj(collab_dir)
|
|
109
|
+
else:
|
|
110
|
+
logging.basicConfig(level=logging.INFO)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _ReconfigurableStream(Protocol):
|
|
114
|
+
"""Protocol for streams that support runtime encoding reconfiguration."""
|
|
115
|
+
|
|
116
|
+
def reconfigure(self, **kwargs: Any) -> Any: ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# UTF-8 encoding (Windows fix — same pattern as validate_code.py / run.py)
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
123
|
+
if hasattr(_stream, "reconfigure"):
|
|
124
|
+
try:
|
|
125
|
+
cast(_ReconfigurableStream, _stream).reconfigure(
|
|
126
|
+
encoding="utf-8",
|
|
127
|
+
errors="replace",
|
|
128
|
+
)
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Logging
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
if callable(setup_collab_logging):
|
|
137
|
+
setup_collab_logging(collab_dir=_COLLAB_ROOT)
|
|
138
|
+
else:
|
|
139
|
+
# Best-effort fallback: configure a simple console logger so runtime
|
|
140
|
+
# still produces useful output even when the collab helper cannot be loaded.
|
|
141
|
+
logging.basicConfig(level=logging.INFO)
|
|
142
|
+
logger = logging.getLogger("collab.pycharm_watcher")
|
|
143
|
+
|
|
144
|
+
# Reduce noisy HTTP client logs (Supabase client uses httpx under the hood)
|
|
145
|
+
for _noisy in ("httpx", "httpx._client", "urllib3", "asyncio"):
|
|
146
|
+
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
|
147
|
+
|
|
148
|
+
# Type annotation: allow create_client to be None until we bind the real factory.
|
|
149
|
+
create_client: Optional[Callable[..., Any]] = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_installed_package_origin(origin_abs: str) -> bool:
|
|
153
|
+
"""Return True when an import origin points to an installed package location."""
|
|
154
|
+
origin_norm = os.path.normcase(origin_abs)
|
|
155
|
+
return (
|
|
156
|
+
f"{os.sep}site-packages{os.sep}" in origin_norm
|
|
157
|
+
or f"{os.sep}dist-packages{os.sep}" in origin_norm
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
_supa_spec = importlib.util.find_spec("supabase")
|
|
163
|
+
except Exception:
|
|
164
|
+
_supa_spec = None
|
|
165
|
+
if _supa_spec is None:
|
|
166
|
+
logger.warning("supabase not installed. Run: pip install supabase")
|
|
167
|
+
# create_client remains None; main() will exit when it detects this.
|
|
168
|
+
else:
|
|
169
|
+
# Defensive diagnostic: ensure we are importing the expected package and
|
|
170
|
+
# not a local file under the project which would
|
|
171
|
+
# shadow the installed package. This has caused failures where a test stub
|
|
172
|
+
# or stray file raised a RuntimeError during watcher startup.
|
|
173
|
+
origin = getattr(_supa_spec, "origin", None)
|
|
174
|
+
try:
|
|
175
|
+
if origin:
|
|
176
|
+
origin_abs = os.path.abspath(origin)
|
|
177
|
+
origin_norm = os.path.normcase(origin_abs)
|
|
178
|
+
collab_norm = os.path.normcase(os.path.abspath(_COLLAB_ROOT))
|
|
179
|
+
project_norm = os.path.normcase(os.path.abspath(_PROJECT_ROOT))
|
|
180
|
+
|
|
181
|
+
# Block local shadow modules in the project before import.
|
|
182
|
+
in_runtime_collab = origin_norm.startswith(collab_norm)
|
|
183
|
+
in_project_tree = origin_norm.startswith(project_norm)
|
|
184
|
+
is_single_file_shadow = origin_norm.endswith(
|
|
185
|
+
os.path.normcase(f"{os.sep}supabase.py")
|
|
186
|
+
)
|
|
187
|
+
is_package_shadow = f"{os.sep}supabase{os.sep}" in origin_norm
|
|
188
|
+
is_installed_package = _is_installed_package_origin(origin_abs)
|
|
189
|
+
|
|
190
|
+
if (in_runtime_collab and not is_installed_package) or (
|
|
191
|
+
in_project_tree
|
|
192
|
+
and not is_installed_package
|
|
193
|
+
and (is_single_file_shadow or is_package_shadow)
|
|
194
|
+
):
|
|
195
|
+
logger.error(
|
|
196
|
+
"Detected local module 'supabase' at %s "
|
|
197
|
+
"which shadows the installed package.",
|
|
198
|
+
origin_abs,
|
|
199
|
+
)
|
|
200
|
+
logger.error(
|
|
201
|
+
"Remove or rename this file/folder and restart the watcher."
|
|
202
|
+
)
|
|
203
|
+
sys.exit(1)
|
|
204
|
+
except Exception:
|
|
205
|
+
# Best-effort diagnostics only; continue to import below if something
|
|
206
|
+
# went wrong inspecting the origin.
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
_supa = import_module("supabase")
|
|
210
|
+
create_client = getattr(_supa, "create_client", None)
|
|
211
|
+
if create_client is None:
|
|
212
|
+
logger.error(
|
|
213
|
+
"The installed 'supabase' package does not expose 'create_client'."
|
|
214
|
+
)
|
|
215
|
+
logger.error("Ensure supabase-py is correctly installed and up to date.")
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
_ply_spec = importlib.util.find_spec("plyer")
|
|
219
|
+
except Exception:
|
|
220
|
+
_ply_spec = None
|
|
221
|
+
if _ply_spec is None:
|
|
222
|
+
desktop_notify = None
|
|
223
|
+
logger.warning(
|
|
224
|
+
"plyer not installed — desktop notifications disabled. Run: pip install plyer"
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
_ply = import_module("plyer")
|
|
228
|
+
desktop_notify = getattr(_ply, "notification", None)
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Configuration
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
|
234
|
+
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY")
|
|
235
|
+
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
|
236
|
+
# PID file lives at project root unless overridden.
|
|
237
|
+
# Tests can override this via COLLAB_PID_FILE env var to avoid interfering with
|
|
238
|
+
# the live production watcher.
|
|
239
|
+
PID_FILE = os.getenv("COLLAB_PID_FILE") or os.path.join(_COLLAB_ROOT, ".daemon.pid")
|
|
240
|
+
DEVELOPER_ID = None
|
|
241
|
+
|
|
242
|
+
# Ephemeral developer prefixes enforced in code (not via env) to avoid
|
|
243
|
+
# accidental disabling of lock persistence. These accounts (e.g. CI/test)
|
|
244
|
+
# will not write locks to the remote DB and are used by automated runners.
|
|
245
|
+
EPHEMERAL_PREFIXES = ["test_dev", "ci"]
|
|
246
|
+
|
|
247
|
+
# Expiry semantics: disabled. The DB RPC ignores time-based expiry and locks
|
|
248
|
+
# persist until explicitly released. The watcher does not send an expires_at
|
|
249
|
+
# value when acquiring locks.
|
|
250
|
+
|
|
251
|
+
# Track files currently in conflict (locked by another dev)
|
|
252
|
+
_active_conflicts: set[str] = set()
|
|
253
|
+
# Track remote locks we already warned about (avoid duplicate notifications)
|
|
254
|
+
_warned_remote_locks: set[str] = set()
|
|
255
|
+
# Track all remote locks last seen (used to surface any add/remove activity)
|
|
256
|
+
_known_remote_locks: set[str] = set()
|
|
257
|
+
# Track locks this watcher process has acquired locally (avoid duplicate notices)
|
|
258
|
+
_local_owned_locks: set[str] = set()
|
|
259
|
+
# Guard to prevent _graceful_shutdown from running more than once
|
|
260
|
+
_shutdown_done: bool = False
|
|
261
|
+
|
|
262
|
+
# URL of the running dashboard server (set in main after _start_dashboard_server).
|
|
263
|
+
# Used by interactive conflict menus so users can review all active locks.
|
|
264
|
+
_dashboard_url: str | None = None
|
|
265
|
+
|
|
266
|
+
# Stable session token for this watcher process lifetime.
|
|
267
|
+
# Used as lock_token for all locks acquired by this session,
|
|
268
|
+
# enabling multi-machine/multi-session detection.
|
|
269
|
+
# Initialized at runtime in main() once DEVELOPER_ID is known.
|
|
270
|
+
SESSION_TOKEN: str = ""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _get_developer_id() -> str:
|
|
274
|
+
"""Derive developer identity from git config or environment."""
|
|
275
|
+
try:
|
|
276
|
+
name = (
|
|
277
|
+
subprocess.check_output(
|
|
278
|
+
["git", "config", "user.name"],
|
|
279
|
+
stderr=subprocess.DEVNULL,
|
|
280
|
+
)
|
|
281
|
+
.decode()
|
|
282
|
+
.strip()
|
|
283
|
+
)
|
|
284
|
+
if name:
|
|
285
|
+
return name
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
logger.debug("git config user.name unavailable, using env fallback: %s", exc)
|
|
288
|
+
return os.getenv("USERNAME") or os.getenv("USER") or "unknown_user"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _get_session_token(dev_id: str) -> str:
|
|
292
|
+
"""Return a stable session token for the current machine, project and user.
|
|
293
|
+
|
|
294
|
+
Must NEVER fall back to a random value — a random token breaks cross-IDE re-adoption
|
|
295
|
+
because it cannot be reconstructed. If derivation fails for any component, use a
|
|
296
|
+
safe fallback value for that component rather than giving up entirely.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
dev_id_norm = str(dev_id).strip().lower() if dev_id else "unknown"
|
|
300
|
+
except Exception:
|
|
301
|
+
dev_id_norm = "unknown"
|
|
302
|
+
try:
|
|
303
|
+
hostname = socket.gethostname().lower()
|
|
304
|
+
except Exception:
|
|
305
|
+
hostname = "localhost"
|
|
306
|
+
try:
|
|
307
|
+
p_root = os.path.abspath(_PROJECT_ROOT).lower().rstrip("\\/")
|
|
308
|
+
except Exception:
|
|
309
|
+
p_root = _PROJECT_ROOT.lower().rstrip("\\/") if _PROJECT_ROOT else "project"
|
|
310
|
+
|
|
311
|
+
seed = f"{dev_id_norm}:{hostname}:{p_root}"
|
|
312
|
+
return hashlib.sha256(seed.encode()).hexdigest()[:16]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _is_same_machine_token(stored_token: str) -> bool:
|
|
316
|
+
"""Return True if stored_token looks like it was generated on this machine.
|
|
317
|
+
|
|
318
|
+
Tries multiple plausible developer ID and path variants to account for environment
|
|
319
|
+
differences between IDEs (e.g. VSCode vs PyCharm terminals may yield slightly
|
|
320
|
+
different git config outputs or working directories).
|
|
321
|
+
"""
|
|
322
|
+
hostname = socket.gethostname().lower()
|
|
323
|
+
p_root = os.path.abspath(_PROJECT_ROOT).lower().rstrip("\\/")
|
|
324
|
+
|
|
325
|
+
# Gather candidate developer IDs to try
|
|
326
|
+
candidates: list[str] = []
|
|
327
|
+
if DEVELOPER_ID:
|
|
328
|
+
candidates.append(str(DEVELOPER_ID).lower())
|
|
329
|
+
# Also try stripped variants in case of whitespace differences
|
|
330
|
+
candidates.append(str(DEVELOPER_ID).strip().lower())
|
|
331
|
+
|
|
332
|
+
# Also try git config user.name directly from the current environment
|
|
333
|
+
try:
|
|
334
|
+
git_name = (
|
|
335
|
+
subprocess.check_output(
|
|
336
|
+
["git", "config", "user.name"],
|
|
337
|
+
stderr=subprocess.DEVNULL,
|
|
338
|
+
)
|
|
339
|
+
.decode()
|
|
340
|
+
.strip()
|
|
341
|
+
.lower()
|
|
342
|
+
)
|
|
343
|
+
if git_name:
|
|
344
|
+
candidates.append(git_name)
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
logger.debug("git config user.name lookup failed in token check: %s", exc)
|
|
347
|
+
|
|
348
|
+
# Also try the system username as fallback
|
|
349
|
+
for env_var in ("USERNAME", "USER", "LOGNAME"):
|
|
350
|
+
val = os.getenv(env_var)
|
|
351
|
+
if val:
|
|
352
|
+
candidates.append(val.lower())
|
|
353
|
+
|
|
354
|
+
# Also try path variants (with/without trailing slash)
|
|
355
|
+
path_variants = [p_root, p_root.rstrip("/\\"), p_root + "/", p_root + "\\"]
|
|
356
|
+
|
|
357
|
+
seen_seeds: set[str] = set()
|
|
358
|
+
for dev_id in set(candidates):
|
|
359
|
+
for p in path_variants:
|
|
360
|
+
seed = f"{dev_id}:{hostname}:{p}"
|
|
361
|
+
if seed in seen_seeds:
|
|
362
|
+
continue
|
|
363
|
+
seen_seeds.add(seed)
|
|
364
|
+
token = hashlib.sha256(seed.encode()).hexdigest()[:16]
|
|
365
|
+
if token == stored_token:
|
|
366
|
+
logger.debug(
|
|
367
|
+
"Token matched same-machine variant: dev_id=%r path=%r", dev_id, p
|
|
368
|
+
)
|
|
369
|
+
return True
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _get_current_branch() -> str:
|
|
374
|
+
"""Return the current git branch name."""
|
|
375
|
+
try:
|
|
376
|
+
if sys.platform == "win32":
|
|
377
|
+
return (
|
|
378
|
+
subprocess.check_output(
|
|
379
|
+
["git", "branch", "--show-current"],
|
|
380
|
+
stderr=subprocess.DEVNULL,
|
|
381
|
+
creationflags=0x08000000,
|
|
382
|
+
)
|
|
383
|
+
.decode()
|
|
384
|
+
.strip()
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
return (
|
|
388
|
+
subprocess.check_output(
|
|
389
|
+
["git", "branch", "--show-current"],
|
|
390
|
+
stderr=subprocess.DEVNULL,
|
|
391
|
+
)
|
|
392
|
+
.decode()
|
|
393
|
+
.strip()
|
|
394
|
+
)
|
|
395
|
+
except Exception:
|
|
396
|
+
return "unknown"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _parse_git_status_path(line: str) -> str:
|
|
400
|
+
"""Extract file path from git status --porcelain line."""
|
|
401
|
+
p = line[3:].strip()
|
|
402
|
+
if " -> " in p:
|
|
403
|
+
p = p.split(" -> ")[-1].strip()
|
|
404
|
+
if p.startswith('"') and p.endswith('"'):
|
|
405
|
+
p = p[1:-1]
|
|
406
|
+
return p
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _normalize_path(path: str, project_root: str) -> str:
|
|
410
|
+
"""Normalise a file path to a canonical project-relative Unix-style key.
|
|
411
|
+
|
|
412
|
+
- Converts backslashes to forward slashes.
|
|
413
|
+
- Strips a leading ``./`` if present.
|
|
414
|
+
- Canonicalises runtime-relative paths consistently.
|
|
415
|
+
- Uses ``os.path.relpath`` when the path is absolute.
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
if os.path.isabs(path):
|
|
419
|
+
path = os.path.relpath(path, project_root)
|
|
420
|
+
path = path.replace("\\", "/")
|
|
421
|
+
if path.startswith("./"):
|
|
422
|
+
path = path[2:]
|
|
423
|
+
|
|
424
|
+
return path
|
|
425
|
+
except Exception:
|
|
426
|
+
return path.replace("\\", "/")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _should_ignore_path(path: str) -> bool:
|
|
430
|
+
"""Return True for paths the watcher should skip."""
|
|
431
|
+
norm = path.replace("\\", "/")
|
|
432
|
+
if "/.git/" in norm or norm.startswith(".git/"):
|
|
433
|
+
return True
|
|
434
|
+
# Ignore runtime instance folders: they are environment artifacts and
|
|
435
|
+
# should not produce collaborative file locks.
|
|
436
|
+
if (
|
|
437
|
+
norm == "instance"
|
|
438
|
+
or norm.startswith("instance/")
|
|
439
|
+
or norm.endswith("/instance")
|
|
440
|
+
or "/instance/" in norm
|
|
441
|
+
):
|
|
442
|
+
return True
|
|
443
|
+
# Do not ignore runtime-relative project paths here.
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _color(text: str, color: str) -> str:
|
|
448
|
+
if not _HAS_COLORAMA:
|
|
449
|
+
return text
|
|
450
|
+
return f"{color}{text}{Style.RESET_ALL}"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _is_ephemeral_dev(dev_id: Optional[str]) -> bool:
|
|
454
|
+
if not dev_id:
|
|
455
|
+
return False
|
|
456
|
+
for p in EPHEMERAL_PREFIXES:
|
|
457
|
+
if dev_id.startswith(p):
|
|
458
|
+
return True
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# (No _compute_expires_at) - watcher intentionally does not compute or send
|
|
463
|
+
# expires_at. The DB handles locks as persistent until release.
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _notify(title: str, message: str) -> None:
|
|
467
|
+
"""Send a desktop notification if plyer is available."""
|
|
468
|
+
if os.getenv("COLLAB_TEST_MODE") == "1":
|
|
469
|
+
return
|
|
470
|
+
if desktop_notify:
|
|
471
|
+
try:
|
|
472
|
+
desktop_notify.notify(
|
|
473
|
+
title=title,
|
|
474
|
+
message=message,
|
|
475
|
+
app_name="Collab Locks",
|
|
476
|
+
timeout=5,
|
|
477
|
+
)
|
|
478
|
+
except Exception:
|
|
479
|
+
logger.info("[Notification] %s: %s", title, message)
|
|
480
|
+
else:
|
|
481
|
+
logger.info("[Notification] %s: %s", title, message)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _is_process_alive(pid: int) -> bool:
|
|
485
|
+
"""Check if a process is alive."""
|
|
486
|
+
if sys.platform == "win32":
|
|
487
|
+
try:
|
|
488
|
+
import psutil
|
|
489
|
+
|
|
490
|
+
return bool(psutil.pid_exists(pid))
|
|
491
|
+
except ImportError:
|
|
492
|
+
try:
|
|
493
|
+
out = subprocess.check_output(
|
|
494
|
+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
495
|
+
text=True,
|
|
496
|
+
creationflags=0x08000000,
|
|
497
|
+
)
|
|
498
|
+
return str(pid) in out
|
|
499
|
+
except Exception:
|
|
500
|
+
return False
|
|
501
|
+
else:
|
|
502
|
+
try:
|
|
503
|
+
os.kill(pid, 0)
|
|
504
|
+
return True
|
|
505
|
+
except ProcessLookupError:
|
|
506
|
+
return False
|
|
507
|
+
except PermissionError:
|
|
508
|
+
return True
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _scan_remote_locks(client) -> None:
|
|
512
|
+
"""Fetch all active locks and warn about files locked by other developers.
|
|
513
|
+
|
|
514
|
+
This runs independently of ``git status`` so the user receives conflict warnings
|
|
515
|
+
*before* saving a file. Only new remote locks trigger a desktop notification
|
|
516
|
+
(tracked via ``_warned_remote_locks``).
|
|
517
|
+
"""
|
|
518
|
+
# Only rebind `_known_remote_locks` in this function; the other sets are
|
|
519
|
+
# mutated in-place (add/discard) so they do not need a `global` declaration.
|
|
520
|
+
global _known_remote_locks
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
res = client.table("file_locks").select("*").execute()
|
|
524
|
+
data = getattr(res, "data", None) or []
|
|
525
|
+
except Exception as exc:
|
|
526
|
+
logger.warning(
|
|
527
|
+
"Remote lock scan failed — conflict warnings may be stale: %s", exc
|
|
528
|
+
)
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
# Build set of all remote file paths (normalize separators) and map full lock rows
|
|
532
|
+
current_remote_all: set[str] = set()
|
|
533
|
+
owner_map: dict[str, dict] = {}
|
|
534
|
+
for lock in data:
|
|
535
|
+
owner = lock.get("developer_id", "")
|
|
536
|
+
fp = lock.get("file_path", "")
|
|
537
|
+
if not fp:
|
|
538
|
+
continue
|
|
539
|
+
fp = fp.replace("\\", "/")
|
|
540
|
+
current_remote_all.add(fp)
|
|
541
|
+
owner_map[fp] = lock
|
|
542
|
+
|
|
543
|
+
# If this watcher already acquired this lock locally, skip notifications
|
|
544
|
+
if owner == DEVELOPER_ID and fp in _local_owned_locks:
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
# If the lock belongs to the same developer but a different session,
|
|
548
|
+
# surface it as a normal locked message (not a conflict) and include
|
|
549
|
+
# metadata (owner, branch, reason) so terminal output mirrors
|
|
550
|
+
# `python run.py active` or `collab active`.
|
|
551
|
+
if owner == DEVELOPER_ID:
|
|
552
|
+
if fp not in _known_remote_locks:
|
|
553
|
+
br = lock.get("branch_name") or "main"
|
|
554
|
+
reason = lock.get("reason") or "No reason"
|
|
555
|
+
msg = f"🔒 [LOCKED] {fp} — @{owner} (branch: {br}, reason: {reason})"
|
|
556
|
+
logger.debug(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
# For locks owned by others: warn once per lock and include branch/reason
|
|
560
|
+
if owner != DEVELOPER_ID and fp not in _warned_remote_locks:
|
|
561
|
+
_warned_remote_locks.add(fp)
|
|
562
|
+
br = lock.get("branch_name") or "main"
|
|
563
|
+
reason = lock.get("reason") or "No reason"
|
|
564
|
+
warn_msg = (
|
|
565
|
+
f"⚠️ REMOTE LOCK: {fp} — @{owner} (branch: {br}, reason: {reason})"
|
|
566
|
+
)
|
|
567
|
+
logger.warning(warn_msg)
|
|
568
|
+
notify_msg = (
|
|
569
|
+
f"{fp} is locked by @{owner}.\n"
|
|
570
|
+
f"branch: {br}\n"
|
|
571
|
+
f"reason: {reason}\n"
|
|
572
|
+
"Coordinate before editing."
|
|
573
|
+
)
|
|
574
|
+
_notify("File Locked", notify_msg)
|
|
575
|
+
|
|
576
|
+
# Clear warnings for locks that were released remotely
|
|
577
|
+
released_warned = _warned_remote_locks - current_remote_all
|
|
578
|
+
if released_warned:
|
|
579
|
+
_warned_remote_locks.difference_update(released_warned)
|
|
580
|
+
for fp in released_warned:
|
|
581
|
+
logger.info("✅ Remote lock cleared: %s", fp)
|
|
582
|
+
|
|
583
|
+
# Surface add/remove activity for remote locks (excluding those owned
|
|
584
|
+
# by this watcher which we suppressed above). Do not re-report locks
|
|
585
|
+
# that we already logged above (same-developer) — filter them out.
|
|
586
|
+
added = current_remote_all - _known_remote_locks
|
|
587
|
+
removed = _known_remote_locks - current_remote_all
|
|
588
|
+
# Filter out additions that correspond to locks we just acquired locally
|
|
589
|
+
# or locks owned by this developer (they were already logged as LOCKED).
|
|
590
|
+
# Filter out additions that correspond to locks we just acquired locally
|
|
591
|
+
# or locks owned by this developer (they were already logged as LOCKED).
|
|
592
|
+
# NOTE: owner_map stores the full lock dict, so compare the stored
|
|
593
|
+
# developer_id field rather than the dict object itself (bugfix).
|
|
594
|
+
filtered_added = {
|
|
595
|
+
p
|
|
596
|
+
for p in added
|
|
597
|
+
if p not in _local_owned_locks
|
|
598
|
+
and (owner_map.get(p, {}).get("developer_id") != DEVELOPER_ID)
|
|
599
|
+
}
|
|
600
|
+
if filtered_added:
|
|
601
|
+
for fp in sorted(filtered_added):
|
|
602
|
+
lk = owner_map.get(fp, {})
|
|
603
|
+
owner = lk.get("developer_id") if lk else "unknown"
|
|
604
|
+
br = lk.get("branch_name") if lk else None
|
|
605
|
+
reason = lk.get("reason") if lk else None
|
|
606
|
+
br = br or "main"
|
|
607
|
+
reason = reason or "No reason"
|
|
608
|
+
# Remote additions should be highlighted so they stand out in the
|
|
609
|
+
# terminal. Use yellow when colorama is available (matches
|
|
610
|
+
# WARNING/CONFLICT color), otherwise plain info text.
|
|
611
|
+
msg = (
|
|
612
|
+
f"🔔 Remote lock added: {fp} — @{owner} "
|
|
613
|
+
f"(branch: {br}, reason: {reason})"
|
|
614
|
+
)
|
|
615
|
+
log_msg = _color(msg, Fore.YELLOW) if _HAS_COLORAMA else msg
|
|
616
|
+
logger.info(log_msg)
|
|
617
|
+
if removed:
|
|
618
|
+
for fp in sorted(removed):
|
|
619
|
+
# If we had recorded it locally, ensure it's removed from that set
|
|
620
|
+
if fp in _local_owned_locks:
|
|
621
|
+
_local_owned_locks.discard(fp)
|
|
622
|
+
# Use the same RELEASED log style as the watcher uses for local releases
|
|
623
|
+
release_msg = f"🔓 [RELEASED] {fp}"
|
|
624
|
+
# Use a distinct color for remote releases so they are visually
|
|
625
|
+
# different from local releases in the terminal output.
|
|
626
|
+
log_msg = _color(release_msg, Fore.CYAN) if _HAS_COLORAMA else release_msg
|
|
627
|
+
logger.info(log_msg)
|
|
628
|
+
_known_remote_locks = current_remote_all
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _process_new_files(client, branch: str, new_files: set[str]) -> None:
|
|
632
|
+
"""Process newly modified files: attempt to acquire locks and handle conflicts.
|
|
633
|
+
|
|
634
|
+
Extracted from the main loop so unit tests can target error/fallback branches (e.g.
|
|
635
|
+
when modifying the local-owned set raises).
|
|
636
|
+
"""
|
|
637
|
+
for fp in new_files:
|
|
638
|
+
try:
|
|
639
|
+
if _is_ephemeral_dev(DEVELOPER_ID):
|
|
640
|
+
msg = f"🔒 [EPHEMERAL] {fp} (not written to DB)"
|
|
641
|
+
logger.info(_color(msg, Fore.CYAN) if _HAS_COLORAMA else msg)
|
|
642
|
+
# skip remote RPC for ephemeral/dev prefixes
|
|
643
|
+
continue
|
|
644
|
+
|
|
645
|
+
res = client.rpc(
|
|
646
|
+
"acquire_lock",
|
|
647
|
+
{
|
|
648
|
+
"p_file_path": fp,
|
|
649
|
+
"p_developer_id": DEVELOPER_ID,
|
|
650
|
+
"p_branch_name": branch,
|
|
651
|
+
"p_reason": "Auto-Watch",
|
|
652
|
+
"p_lock_token": SESSION_TOKEN,
|
|
653
|
+
"p_is_ephemeral": _is_ephemeral_dev(DEVELOPER_ID),
|
|
654
|
+
},
|
|
655
|
+
).execute()
|
|
656
|
+
data = getattr(res, "data", None) or []
|
|
657
|
+
if isinstance(data, list) and data and data[0].get("status") == "conflict":
|
|
658
|
+
owner = data[0].get("owner", "someone")
|
|
659
|
+
_active_conflicts.add(fp)
|
|
660
|
+
msg = (
|
|
661
|
+
f"⚠️ CONFLICT: {fp} is locked by @{owner} -- "
|
|
662
|
+
"your changes may cause a merge conflict."
|
|
663
|
+
)
|
|
664
|
+
log_msg = _color(msg, Fore.YELLOW) if _HAS_COLORAMA else msg
|
|
665
|
+
logger.warning(log_msg)
|
|
666
|
+
notify_msg = (
|
|
667
|
+
f"{fp} is locked by @{owner}.\nCoordinate before committing."
|
|
668
|
+
)
|
|
669
|
+
_notify("Lock Conflict", notify_msg)
|
|
670
|
+
else:
|
|
671
|
+
br_local = branch or "main"
|
|
672
|
+
reason_local = "Auto-Watch"
|
|
673
|
+
msg = (
|
|
674
|
+
f"🔒 [LOCKED] {fp} — @{DEVELOPER_ID} "
|
|
675
|
+
f"(branch: {br_local}, reason: {reason_local})"
|
|
676
|
+
)
|
|
677
|
+
log_msg = _color(msg, Fore.GREEN) if _HAS_COLORAMA else msg
|
|
678
|
+
logger.info(log_msg)
|
|
679
|
+
# Track locks this watcher created so remote scans do not
|
|
680
|
+
# report them as 'remote added' later.
|
|
681
|
+
_local_owned_locks.add(fp)
|
|
682
|
+
except Exception:
|
|
683
|
+
# Log full traceback so errors during acquire are visible in errors.log
|
|
684
|
+
logger.exception("Failed to acquire lock for %s", fp)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _process_releases(client, released: set[str]) -> None:
|
|
688
|
+
"""Process local releases for files no longer modified.
|
|
689
|
+
|
|
690
|
+
Extracted so tests can simulate exceptions when removing locks from the local-owned
|
|
691
|
+
set without running the entire main loop.
|
|
692
|
+
"""
|
|
693
|
+
for fp in released:
|
|
694
|
+
# Was this file in conflict?
|
|
695
|
+
if fp in _active_conflicts:
|
|
696
|
+
_active_conflicts.discard(fp)
|
|
697
|
+
msg = f"✅ Conflict cleared: {fp} (file reverted or resolved)"
|
|
698
|
+
logger.info(_color(msg, Fore.BLUE) if _HAS_COLORAMA else msg)
|
|
699
|
+
else:
|
|
700
|
+
try:
|
|
701
|
+
if _is_ephemeral_dev(DEVELOPER_ID):
|
|
702
|
+
logger.info("🔓 [EPHEMERAL-RELEASE] %s", fp)
|
|
703
|
+
else:
|
|
704
|
+
client.table("file_locks").delete().eq("file_path", fp).eq(
|
|
705
|
+
"developer_id", DEVELOPER_ID
|
|
706
|
+
).execute()
|
|
707
|
+
logger.info(
|
|
708
|
+
_color(f"🔓 [RELEASED] {fp}", Fore.MAGENTA)
|
|
709
|
+
if _HAS_COLORAMA
|
|
710
|
+
else f"[RELEASED] {fp}"
|
|
711
|
+
)
|
|
712
|
+
# If we released a lock we held locally, remove it
|
|
713
|
+
# from the local-owned set so remote scans don't keep it there.
|
|
714
|
+
_local_owned_locks.discard(fp)
|
|
715
|
+
except Exception:
|
|
716
|
+
# Ensure full traceback is captured in errors.log for diagnostics
|
|
717
|
+
logger.exception("Failed to release lock for %s", fp)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _start_dashboard_server() -> str | None:
|
|
721
|
+
"""Start a local HTTP server serving the dashboard and return the URL.
|
|
722
|
+
|
|
723
|
+
Returns the ``http://127.0.0.1:<port>/...`` URL that terminals render
|
|
724
|
+
as a clickable link, or *None* on failure.
|
|
725
|
+
"""
|
|
726
|
+
import http.server
|
|
727
|
+
import json as _json
|
|
728
|
+
import tempfile
|
|
729
|
+
from functools import partial
|
|
730
|
+
|
|
731
|
+
html_path = os.path.join(_RESOURCE_ROOT, "dashboard", "index.html")
|
|
732
|
+
if not os.path.exists(html_path):
|
|
733
|
+
logger.warning("Dashboard HTML not found at %s", html_path)
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
with open(html_path, "r", encoding="utf-8") as fh:
|
|
738
|
+
content = fh.read()
|
|
739
|
+
except Exception as exc:
|
|
740
|
+
logger.warning("Failed to read dashboard template: %s", exc)
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
injected = {
|
|
744
|
+
"url": SUPABASE_URL or "",
|
|
745
|
+
"anonKey": SUPABASE_ANON_KEY or "",
|
|
746
|
+
"serviceKey": SUPABASE_SERVICE_ROLE_KEY or None,
|
|
747
|
+
"user": DEVELOPER_ID or "",
|
|
748
|
+
}
|
|
749
|
+
inject_script = (
|
|
750
|
+
f"<script>window.__SUPABASE_CONFIG__ = {_json.dumps(injected)};</script>\n"
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
try:
|
|
754
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
755
|
+
mode="w", delete=False, suffix=".html", encoding="utf-8"
|
|
756
|
+
)
|
|
757
|
+
tmp.write(inject_script)
|
|
758
|
+
tmp.write(content)
|
|
759
|
+
tmp.flush()
|
|
760
|
+
tmp.close()
|
|
761
|
+
except Exception as exc:
|
|
762
|
+
logger.warning("Failed to create temp dashboard: %s", exc)
|
|
763
|
+
return None
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
tmp_dir = os.path.dirname(tmp.name)
|
|
767
|
+
filename = os.path.basename(tmp.name)
|
|
768
|
+
|
|
769
|
+
handler = partial(http.server.SimpleHTTPRequestHandler, directory=tmp_dir)
|
|
770
|
+
# Silence per-request log noise
|
|
771
|
+
handler_cls = http.server.SimpleHTTPRequestHandler
|
|
772
|
+
handler_cls.log_message = lambda *_a, **_k: None # type: ignore[method-assign]
|
|
773
|
+
|
|
774
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
775
|
+
port = server.server_address[1]
|
|
776
|
+
|
|
777
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
778
|
+
thread.start()
|
|
779
|
+
atexit.register(server.shutdown)
|
|
780
|
+
|
|
781
|
+
return f"http://127.0.0.1:{port}/{filename}"
|
|
782
|
+
except Exception as exc:
|
|
783
|
+
logger.warning("Failed to start dashboard server: %s", exc)
|
|
784
|
+
try:
|
|
785
|
+
os.unlink(tmp.name)
|
|
786
|
+
except Exception as cleanup_exc:
|
|
787
|
+
logger.debug("Dashboard temp-file cleanup failed: %s", cleanup_exc)
|
|
788
|
+
return None
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _get_modified_and_unpushed_files() -> set[str]:
|
|
792
|
+
"""Return the set of files that are 'in progress' for this developer.
|
|
793
|
+
|
|
794
|
+
Includes both:
|
|
795
|
+
- Dirty/staged files (git status --porcelain)
|
|
796
|
+
- Committed but not yet pushed files (git diff @{u}..HEAD)
|
|
797
|
+
|
|
798
|
+
This matches the definition used by lock_client.py to ensure both watchers
|
|
799
|
+
agree on which files should be locked.
|
|
800
|
+
"""
|
|
801
|
+
result: set[str] = set()
|
|
802
|
+
kwargs: dict[str, Any] = {"stderr": subprocess.DEVNULL}
|
|
803
|
+
if sys.platform == "win32":
|
|
804
|
+
kwargs["creationflags"] = 0x08000000
|
|
805
|
+
|
|
806
|
+
# Part 1: dirty/staged files
|
|
807
|
+
try:
|
|
808
|
+
out = (
|
|
809
|
+
subprocess.check_output(["git", "status", "--porcelain"], **kwargs)
|
|
810
|
+
.decode()
|
|
811
|
+
.strip()
|
|
812
|
+
)
|
|
813
|
+
if out:
|
|
814
|
+
for line in out.splitlines():
|
|
815
|
+
if len(line) > 3:
|
|
816
|
+
p = _normalize_path(_parse_git_status_path(line), _PROJECT_ROOT)
|
|
817
|
+
if not _should_ignore_path(p):
|
|
818
|
+
result.add(p)
|
|
819
|
+
except Exception as exc:
|
|
820
|
+
logger.warning("git status failed in file-change detection: %s", exc)
|
|
821
|
+
|
|
822
|
+
# Part 2: committed but unpushed files
|
|
823
|
+
try:
|
|
824
|
+
# First verify an upstream branch exists; if not, skip silently
|
|
825
|
+
subprocess.check_output(
|
|
826
|
+
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
|
827
|
+
**kwargs,
|
|
828
|
+
)
|
|
829
|
+
diff_out = (
|
|
830
|
+
subprocess.check_output(
|
|
831
|
+
["git", "diff", "--name-only", "@{u}..HEAD"], **kwargs
|
|
832
|
+
)
|
|
833
|
+
.decode()
|
|
834
|
+
.strip()
|
|
835
|
+
)
|
|
836
|
+
if diff_out:
|
|
837
|
+
for line in diff_out.splitlines():
|
|
838
|
+
p = _normalize_path(line.strip(), _PROJECT_ROOT)
|
|
839
|
+
if p and not _should_ignore_path(p):
|
|
840
|
+
result.add(p)
|
|
841
|
+
except Exception:
|
|
842
|
+
# No upstream configured or diff failed — silently fall back to
|
|
843
|
+
# status-only. This is safe: we just won't lock unpushed files,
|
|
844
|
+
# which is better than crashing.
|
|
845
|
+
pass
|
|
846
|
+
|
|
847
|
+
return result
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _run_git_status_porcelain() -> set[str]:
|
|
851
|
+
"""Compatibility shim used by tests.
|
|
852
|
+
|
|
853
|
+
Older tests monkeypatch `_run_git_status_porcelain`. Delegate to the current
|
|
854
|
+
implementation to keep tests backward-compatible.
|
|
855
|
+
"""
|
|
856
|
+
return _get_modified_and_unpushed_files()
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _reconcile_on_startup(client) -> None:
|
|
860
|
+
"""Reconcile Supabase lock state with local git state at watcher startup.
|
|
861
|
+
|
|
862
|
+
Handles the case where the watcher was shut down while files were still dirty. Re-
|
|
863
|
+
adopts valid locks, releases stale ones, acquires new ones, and surfaces post-
|
|
864
|
+
restart conflicts.
|
|
865
|
+
"""
|
|
866
|
+
if _is_ephemeral_dev(DEVELOPER_ID):
|
|
867
|
+
logger.info("Ephemeral developer — skipping startup reconciliation.")
|
|
868
|
+
return
|
|
869
|
+
|
|
870
|
+
logger.debug("Starting lock reconciliation...")
|
|
871
|
+
|
|
872
|
+
# Step A: Fetch existing owned locks from Supabase
|
|
873
|
+
try:
|
|
874
|
+
res = (
|
|
875
|
+
client.table("file_locks")
|
|
876
|
+
.select("*")
|
|
877
|
+
.eq("developer_id", DEVELOPER_ID)
|
|
878
|
+
.execute()
|
|
879
|
+
)
|
|
880
|
+
existing_locks = getattr(res, "data", None) or []
|
|
881
|
+
except Exception as exc:
|
|
882
|
+
logger.warning("Failed to fetch existing locks during reconciliation: %s", exc)
|
|
883
|
+
return
|
|
884
|
+
|
|
885
|
+
# Step B: Get files that are in progress (dirty OR committed-but-unpushed)
|
|
886
|
+
try:
|
|
887
|
+
dirty_files = _run_git_status_porcelain()
|
|
888
|
+
except Exception as exc:
|
|
889
|
+
logger.warning("git status failed during reconciliation: %s", exc)
|
|
890
|
+
return
|
|
891
|
+
|
|
892
|
+
# Build lookup maps
|
|
893
|
+
lock_map: dict[str, dict] = {}
|
|
894
|
+
for lock in existing_locks:
|
|
895
|
+
fp = lock.get("file_path", "")
|
|
896
|
+
if fp:
|
|
897
|
+
lock_map[fp] = lock
|
|
898
|
+
|
|
899
|
+
locked_paths = set(lock_map.keys())
|
|
900
|
+
branch = _get_current_branch()
|
|
901
|
+
|
|
902
|
+
n_readopted = 0
|
|
903
|
+
n_stale_released = 0
|
|
904
|
+
n_newly_locked = 0
|
|
905
|
+
n_conflicts = 0
|
|
906
|
+
|
|
907
|
+
# Step C: Process each existing lock owned by this developer
|
|
908
|
+
n_multi_session = 0
|
|
909
|
+
for fp, lock in lock_map.items():
|
|
910
|
+
stored_token = lock.get("lock_token", "")
|
|
911
|
+
|
|
912
|
+
if fp in dirty_files:
|
|
913
|
+
# File is still dirty — potential re-adopt
|
|
914
|
+
if stored_token and stored_token != SESSION_TOKEN:
|
|
915
|
+
# Before treating as multi-session, check if the lock was acquired on
|
|
916
|
+
# THIS machine by verifying the stored token matches what this machine
|
|
917
|
+
# would have generated with any reasonable developer ID variation.
|
|
918
|
+
# If so, silently re-adopt (token mismatch is just an IDE
|
|
919
|
+
# environment difference).
|
|
920
|
+
if _is_same_machine_token(stored_token):
|
|
921
|
+
# Re-adopt silently, but update the token so future checks use the
|
|
922
|
+
# current session token.
|
|
923
|
+
try:
|
|
924
|
+
client.table("file_locks").update(
|
|
925
|
+
{"lock_token": SESSION_TOKEN}
|
|
926
|
+
).eq("file_path", fp).eq("developer_id", DEVELOPER_ID).execute()
|
|
927
|
+
except Exception as exc:
|
|
928
|
+
logger.warning(
|
|
929
|
+
"Failed to update lock_token for %s — "
|
|
930
|
+
"future restarts may re-trigger MULTI-SESSION: %s",
|
|
931
|
+
fp,
|
|
932
|
+
exc,
|
|
933
|
+
)
|
|
934
|
+
_local_owned_locks.add(fp)
|
|
935
|
+
n_readopted += 1
|
|
936
|
+
msg = f"🔒 [RESUMED] {fp} — lock re-adopted from this machine"
|
|
937
|
+
logger.info(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
938
|
+
else:
|
|
939
|
+
# Different session token — possible multi-machine scenario
|
|
940
|
+
n_multi_session += 1
|
|
941
|
+
_handle_multi_session_lock(client, fp, stored_token)
|
|
942
|
+
else:
|
|
943
|
+
# Same session or no token mismatch — safe to re-adopt
|
|
944
|
+
# Update the lock_token to the current session so future restarts can
|
|
945
|
+
# re-adopt this lock without hitting MULTI-SESSION.
|
|
946
|
+
try:
|
|
947
|
+
client.table("file_locks").update({"lock_token": SESSION_TOKEN}).eq(
|
|
948
|
+
"file_path", fp
|
|
949
|
+
).eq("developer_id", DEVELOPER_ID).execute()
|
|
950
|
+
except Exception as exc:
|
|
951
|
+
logger.warning(
|
|
952
|
+
"Failed to refresh lock_token for %s — "
|
|
953
|
+
"future restarts may re-trigger MULTI-SESSION: %s",
|
|
954
|
+
fp,
|
|
955
|
+
exc,
|
|
956
|
+
)
|
|
957
|
+
_local_owned_locks.add(fp)
|
|
958
|
+
n_readopted += 1
|
|
959
|
+
msg = f"🔒 [RESUMED] {fp} — lock re-adopted from this machine"
|
|
960
|
+
logger.info(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
961
|
+
else:
|
|
962
|
+
# File is clean — stale lock, release it
|
|
963
|
+
try:
|
|
964
|
+
client.table("file_locks").delete().eq("file_path", fp).eq(
|
|
965
|
+
"developer_id", DEVELOPER_ID
|
|
966
|
+
).execute()
|
|
967
|
+
n_stale_released += 1
|
|
968
|
+
msg = (
|
|
969
|
+
f"🔓 [STALE-RELEASED] {fp} — locked but file is "
|
|
970
|
+
"now clean, releasing"
|
|
971
|
+
)
|
|
972
|
+
logger.info(_color(msg, Fore.MAGENTA) if _HAS_COLORAMA else msg)
|
|
973
|
+
except Exception:
|
|
974
|
+
logger.exception("Failed to release stale lock for %s", fp)
|
|
975
|
+
|
|
976
|
+
# Step D: Acquire locks for dirty files that have no existing lock
|
|
977
|
+
unlocked_dirty = dirty_files - locked_paths
|
|
978
|
+
for fp in sorted(unlocked_dirty):
|
|
979
|
+
if _should_ignore_path(fp):
|
|
980
|
+
continue
|
|
981
|
+
try:
|
|
982
|
+
res = client.rpc(
|
|
983
|
+
"acquire_lock",
|
|
984
|
+
{
|
|
985
|
+
"p_file_path": fp,
|
|
986
|
+
"p_developer_id": DEVELOPER_ID,
|
|
987
|
+
"p_branch_name": branch,
|
|
988
|
+
"p_reason": "Auto-Watch (resumed)",
|
|
989
|
+
"p_lock_token": SESSION_TOKEN,
|
|
990
|
+
"p_is_ephemeral": False,
|
|
991
|
+
},
|
|
992
|
+
).execute()
|
|
993
|
+
data = getattr(res, "data", None) or []
|
|
994
|
+
if isinstance(data, list) and data and data[0].get("status") == "conflict":
|
|
995
|
+
n_conflicts += 1
|
|
996
|
+
_handle_post_restart_conflict(client, fp, data[0])
|
|
997
|
+
else:
|
|
998
|
+
_local_owned_locks.add(fp)
|
|
999
|
+
n_newly_locked += 1
|
|
1000
|
+
msg = f"🔒 [LOCKED] {fp} — acquired lock for dirty file at startup"
|
|
1001
|
+
logger.debug(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
1002
|
+
except Exception:
|
|
1003
|
+
logger.exception("Failed to acquire lock for %s during reconciliation", fp)
|
|
1004
|
+
|
|
1005
|
+
# Step E: Log reconciliation summary
|
|
1006
|
+
summary = (
|
|
1007
|
+
f"Startup reconciliation complete.\n"
|
|
1008
|
+
f" Re-adopted: {n_readopted} lock(s)\n"
|
|
1009
|
+
f" Stale released: {n_stale_released} lock(s)\n"
|
|
1010
|
+
f" Newly locked: {n_newly_locked} file(s)\n"
|
|
1011
|
+
f" Conflicts: {n_conflicts} file(s)"
|
|
1012
|
+
)
|
|
1013
|
+
if n_conflicts > 0:
|
|
1014
|
+
summary += " — review required"
|
|
1015
|
+
if n_multi_session > 0:
|
|
1016
|
+
summary += (
|
|
1017
|
+
f"\n Multi-session: {n_multi_session} lock(s) "
|
|
1018
|
+
"left under different session tokens"
|
|
1019
|
+
)
|
|
1020
|
+
logger.info(summary)
|
|
1021
|
+
info_msg = (
|
|
1022
|
+
f"ℹ️ {n_multi_session} lock(s) left under different session tokens. "
|
|
1023
|
+
"Run 'collab active' to review."
|
|
1024
|
+
)
|
|
1025
|
+
logger.info(_color(info_msg, Fore.CYAN) if _HAS_COLORAMA else info_msg)
|
|
1026
|
+
else:
|
|
1027
|
+
logger.info(summary)
|
|
1028
|
+
|
|
1029
|
+
# Single batched notification for all startup reconciliation activity
|
|
1030
|
+
notification_title = "Collab Locks — Startup Summary"
|
|
1031
|
+
notification_msg = (
|
|
1032
|
+
f"Re-adopted: {n_readopted} lock(s)\n"
|
|
1033
|
+
f"Stale released: {n_stale_released} lock(s)\n"
|
|
1034
|
+
f"Newly locked: {n_newly_locked} file(s)\n"
|
|
1035
|
+
f"Conflicts: {n_conflicts} file(s)"
|
|
1036
|
+
)
|
|
1037
|
+
if n_multi_session > 0:
|
|
1038
|
+
notification_msg += f"\nMulti-session: {n_multi_session} lock(s)"
|
|
1039
|
+
if n_conflicts > 0:
|
|
1040
|
+
notification_msg += " — review required"
|
|
1041
|
+
_notify(notification_title, notification_msg)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _handle_multi_session_lock(client, fp: str, stored_token: str) -> None:
|
|
1045
|
+
"""Handle a lock held by the same developer but from a different session.
|
|
1046
|
+
|
|
1047
|
+
Interactive mode prompts the developer to decide; non-interactive defaults to
|
|
1048
|
+
leaving the lock untouched (safe default — the other session may still be active).
|
|
1049
|
+
"""
|
|
1050
|
+
if sys.stdin.isatty():
|
|
1051
|
+
print(f"\n⚠️ [MULTI-SESSION] {fp}")
|
|
1052
|
+
print(
|
|
1053
|
+
f" Lock held by @{DEVELOPER_ID} from a different session "
|
|
1054
|
+
f"(token: {stored_token[:8]}...)"
|
|
1055
|
+
)
|
|
1056
|
+
print(" Are you running the watcher on multiple machines?\n")
|
|
1057
|
+
print(" [1] Re-adopt this lock for the current session")
|
|
1058
|
+
print(" [2] Leave it — another machine may still be active")
|
|
1059
|
+
print(" [3] Release it — the other session is no longer active")
|
|
1060
|
+
try:
|
|
1061
|
+
choice = input(" Enter choice [1/2/3]: ").strip()
|
|
1062
|
+
except (EOFError, KeyboardInterrupt):
|
|
1063
|
+
choice = "2"
|
|
1064
|
+
|
|
1065
|
+
if choice == "1":
|
|
1066
|
+
try:
|
|
1067
|
+
client.table("file_locks").update({"lock_token": SESSION_TOKEN}).eq(
|
|
1068
|
+
"file_path", fp
|
|
1069
|
+
).eq("developer_id", DEVELOPER_ID).execute()
|
|
1070
|
+
except Exception:
|
|
1071
|
+
logger.exception("Failed to update lock_token for %s", fp)
|
|
1072
|
+
_local_owned_locks.add(fp)
|
|
1073
|
+
msg = f"🔒 [RESUMED] {fp} — lock re-adopted from different session"
|
|
1074
|
+
logger.info(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
1075
|
+
_notify(
|
|
1076
|
+
"Lock Resumed",
|
|
1077
|
+
f"{fp} — lock re-adopted from different session",
|
|
1078
|
+
)
|
|
1079
|
+
elif choice == "3":
|
|
1080
|
+
try:
|
|
1081
|
+
client.table("file_locks").delete().eq("file_path", fp).eq(
|
|
1082
|
+
"developer_id", DEVELOPER_ID
|
|
1083
|
+
).execute()
|
|
1084
|
+
except Exception:
|
|
1085
|
+
logger.exception("Failed to release lock for %s", fp)
|
|
1086
|
+
msg = f"🔓 [RELEASED] {fp} — released per user request"
|
|
1087
|
+
logger.info(_color(msg, Fore.MAGENTA) if _HAS_COLORAMA else msg)
|
|
1088
|
+
else:
|
|
1089
|
+
msg = (
|
|
1090
|
+
f"⚠️ [MULTI-SESSION] {fp} — left to other session "
|
|
1091
|
+
f"(token: {stored_token[:8]}...)"
|
|
1092
|
+
)
|
|
1093
|
+
logger.warning(msg)
|
|
1094
|
+
else:
|
|
1095
|
+
# Non-interactive: default to leave (option 2 — safe default)
|
|
1096
|
+
msg = (
|
|
1097
|
+
f"⚠️ [MULTI-SESSION] {fp} — token mismatch "
|
|
1098
|
+
f"(stored: {stored_token[:8]}..., current: {SESSION_TOKEN[:8]}...). "
|
|
1099
|
+
f"Could not confirm same-machine origin. Lock left untouched — "
|
|
1100
|
+
f"use 'collab release-all' if this is stale."
|
|
1101
|
+
)
|
|
1102
|
+
logger.warning(msg)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _handle_post_restart_conflict(client, fp: str, lock_data: dict) -> None:
|
|
1106
|
+
"""Handle a post-restart conflict: dirty locally but locked by another dev.
|
|
1107
|
+
|
|
1108
|
+
Interactive mode presents options; non-interactive defaults to continuing with the
|
|
1109
|
+
file added to the conflict tracking set.
|
|
1110
|
+
"""
|
|
1111
|
+
owner = lock_data.get("owner", "someone")
|
|
1112
|
+
lock_branch = lock_data.get("branch", "unknown")
|
|
1113
|
+
lock_reason = lock_data.get("reason", "N/A")
|
|
1114
|
+
|
|
1115
|
+
conflict_msg = (
|
|
1116
|
+
f"⚠️ [POST-RESTART CONFLICT] {fp} — dirty locally, "
|
|
1117
|
+
f"locked by @{owner}.\n"
|
|
1118
|
+
" Your local edits may conflict. Manual review required."
|
|
1119
|
+
)
|
|
1120
|
+
logger.warning(conflict_msg)
|
|
1121
|
+
_notify("Post-restart conflict", f"{fp} locked by @{owner}")
|
|
1122
|
+
|
|
1123
|
+
if sys.stdin.isatty():
|
|
1124
|
+
fp_display = fp[:50]
|
|
1125
|
+
owner_display = f"@{owner}"[:48]
|
|
1126
|
+
branch_display = str(lock_branch)[:50]
|
|
1127
|
+
reason_display = str(lock_reason)[:50]
|
|
1128
|
+
print(f"\n╔{'═' * 62}╗")
|
|
1129
|
+
print(f"║ ⚠️ POST-RESTART CONFLICT DETECTED{' ' * 26}║")
|
|
1130
|
+
print(f"║{' ' * 63}║")
|
|
1131
|
+
print(f"║ File : {fp_display:<51}║")
|
|
1132
|
+
print(f"║ Locked by: {owner_display:<50}║")
|
|
1133
|
+
print(f"║ Branch : {branch_display:<51}║")
|
|
1134
|
+
print(f"║ Reason : {reason_display:<51}║")
|
|
1135
|
+
print(f"║{' ' * 63}║")
|
|
1136
|
+
print(f"║ This file has local uncommitted edits AND is now{' ' * 12}║")
|
|
1137
|
+
print(f"║ locked by another developer.{' ' * 33}║")
|
|
1138
|
+
print(f"║{' ' * 63}║")
|
|
1139
|
+
print(f"║ Options:{' ' * 53}║")
|
|
1140
|
+
print(f"║ [1] Continue — keep my edits, add to conflicts{' ' * 14}║")
|
|
1141
|
+
print(f"║ [2] Show diff — run `git diff`{' ' * 31}║")
|
|
1142
|
+
print(f"║ [3] Open dashboard — view all active locks{' ' * 18}║")
|
|
1143
|
+
print(f"║ [4] Abort watcher startup{' ' * 36}║")
|
|
1144
|
+
print(f"╚{'═' * 62}╝")
|
|
1145
|
+
|
|
1146
|
+
while True:
|
|
1147
|
+
try:
|
|
1148
|
+
choice = input(" Enter choice [1/2/3/4]: ").strip()
|
|
1149
|
+
except (EOFError, KeyboardInterrupt):
|
|
1150
|
+
choice = "1"
|
|
1151
|
+
|
|
1152
|
+
if choice == "2":
|
|
1153
|
+
try:
|
|
1154
|
+
diff_args = ["git", "diff", fp]
|
|
1155
|
+
diff_kwargs: dict[str, Any] = {
|
|
1156
|
+
"stderr": subprocess.DEVNULL,
|
|
1157
|
+
}
|
|
1158
|
+
if sys.platform == "win32":
|
|
1159
|
+
diff_kwargs["creationflags"] = 0x08000000
|
|
1160
|
+
diff_out = subprocess.check_output(diff_args, **diff_kwargs).decode(
|
|
1161
|
+
errors="replace"
|
|
1162
|
+
)
|
|
1163
|
+
print(f"\n--- git diff {fp} ---")
|
|
1164
|
+
print(diff_out or "(no diff output)")
|
|
1165
|
+
print("---\n")
|
|
1166
|
+
except Exception as exc:
|
|
1167
|
+
print(f" (git diff failed: {exc})")
|
|
1168
|
+
continue
|
|
1169
|
+
elif choice == "3":
|
|
1170
|
+
url = _dashboard_url or _start_dashboard_server()
|
|
1171
|
+
if url:
|
|
1172
|
+
print(f" Opening dashboard: {url}")
|
|
1173
|
+
try:
|
|
1174
|
+
webbrowser.open(url)
|
|
1175
|
+
except Exception as exc:
|
|
1176
|
+
print(f" (Could not open browser: {exc})")
|
|
1177
|
+
else:
|
|
1178
|
+
print(" Dashboard unavailable. Run: collab dashboard")
|
|
1179
|
+
continue
|
|
1180
|
+
elif choice == "4":
|
|
1181
|
+
logger.info("User chose to abort watcher startup.")
|
|
1182
|
+
_graceful_shutdown()
|
|
1183
|
+
sys.exit(1)
|
|
1184
|
+
else:
|
|
1185
|
+
break
|
|
1186
|
+
|
|
1187
|
+
_active_conflicts.add(fp)
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _graceful_shutdown() -> None:
|
|
1191
|
+
"""Release only clean-file locks; keep dirty-file locks in Supabase.
|
|
1192
|
+
|
|
1193
|
+
A lock is released if and only if its file is no longer dirty in
|
|
1194
|
+
``git status --porcelain``. If git status fails, falls back to
|
|
1195
|
+
releasing all locks (legacy behavior) with a WARNING.
|
|
1196
|
+
|
|
1197
|
+
Guarded so it runs at most once, even when invoked from multiple shutdown
|
|
1198
|
+
paths (signal handler, finally block, atexit).
|
|
1199
|
+
"""
|
|
1200
|
+
global _shutdown_done
|
|
1201
|
+
if _shutdown_done or os.getenv("COLLAB_TEST_MODE") == "1":
|
|
1202
|
+
return
|
|
1203
|
+
_shutdown_done = True
|
|
1204
|
+
|
|
1205
|
+
dev_id = DEVELOPER_ID
|
|
1206
|
+
if dev_id and SUPABASE_URL and SUPABASE_ANON_KEY and create_client is not None:
|
|
1207
|
+
try:
|
|
1208
|
+
assert create_client is not None
|
|
1209
|
+
client = cast(Callable[..., Any], create_client)(
|
|
1210
|
+
SUPABASE_URL, SUPABASE_ANON_KEY
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
# Determine which files are still in progress
|
|
1214
|
+
# (dirty OR committed-but-unpushed)
|
|
1215
|
+
still_dirty: set[str] = set()
|
|
1216
|
+
git_failed = False
|
|
1217
|
+
try:
|
|
1218
|
+
still_dirty = _run_git_status_porcelain()
|
|
1219
|
+
except Exception as exc:
|
|
1220
|
+
git_failed = True
|
|
1221
|
+
logger.warning(
|
|
1222
|
+
"WARNING: git status failed during shutdown (%s). "
|
|
1223
|
+
"Falling back to release-all.",
|
|
1224
|
+
exc,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
if git_failed:
|
|
1228
|
+
# Fallback: blanket release (legacy behavior)
|
|
1229
|
+
client.table("file_locks").delete().eq("developer_id", dev_id).execute()
|
|
1230
|
+
logger.info("✅ Released all locks during shutdown (fallback).")
|
|
1231
|
+
else:
|
|
1232
|
+
# Smart release: only release locks for clean files
|
|
1233
|
+
n_kept = 0
|
|
1234
|
+
n_released = 0
|
|
1235
|
+
|
|
1236
|
+
# Release clean files from _local_owned_locks
|
|
1237
|
+
for fp in list(_local_owned_locks):
|
|
1238
|
+
if fp in still_dirty:
|
|
1239
|
+
n_kept += 1
|
|
1240
|
+
msg = f"🔒 [KEPT] {fp} — still has local edits, lock preserved"
|
|
1241
|
+
logger.debug(_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg)
|
|
1242
|
+
else:
|
|
1243
|
+
try:
|
|
1244
|
+
client.table("file_locks").delete().eq("file_path", fp).eq(
|
|
1245
|
+
"developer_id", dev_id
|
|
1246
|
+
).execute()
|
|
1247
|
+
n_released += 1
|
|
1248
|
+
msg = f"🔓 [RELEASED] {fp}"
|
|
1249
|
+
logger.info(
|
|
1250
|
+
_color(msg, Fore.MAGENTA) if _HAS_COLORAMA else msg
|
|
1251
|
+
)
|
|
1252
|
+
except Exception:
|
|
1253
|
+
logger.exception(
|
|
1254
|
+
"Failed to release lock for %s during shutdown",
|
|
1255
|
+
fp,
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
# If _local_owned_locks was empty (e.g. fresh startup),
|
|
1259
|
+
# query Supabase for any locks we might hold
|
|
1260
|
+
if not _local_owned_locks:
|
|
1261
|
+
try:
|
|
1262
|
+
res = (
|
|
1263
|
+
client.table("file_locks")
|
|
1264
|
+
.select("file_path")
|
|
1265
|
+
.eq("developer_id", dev_id)
|
|
1266
|
+
.execute()
|
|
1267
|
+
)
|
|
1268
|
+
db_locks = [
|
|
1269
|
+
r.get("file_path", "")
|
|
1270
|
+
for r in (getattr(res, "data", None) or [])
|
|
1271
|
+
]
|
|
1272
|
+
for fp in db_locks:
|
|
1273
|
+
if fp and fp not in still_dirty:
|
|
1274
|
+
client.table("file_locks").delete().eq(
|
|
1275
|
+
"file_path", fp
|
|
1276
|
+
).eq("developer_id", dev_id).execute()
|
|
1277
|
+
n_released += 1
|
|
1278
|
+
msg = f"🔓 [RELEASED] {fp}"
|
|
1279
|
+
logger.info(
|
|
1280
|
+
_color(msg, Fore.MAGENTA) if _HAS_COLORAMA else msg
|
|
1281
|
+
)
|
|
1282
|
+
elif fp:
|
|
1283
|
+
n_kept += 1
|
|
1284
|
+
msg = (
|
|
1285
|
+
f"🔒 [KEPT] {fp} — still has "
|
|
1286
|
+
"local edits, lock preserved"
|
|
1287
|
+
)
|
|
1288
|
+
logger.debug(
|
|
1289
|
+
_color(msg, Fore.GREEN) if _HAS_COLORAMA else msg
|
|
1290
|
+
)
|
|
1291
|
+
except Exception:
|
|
1292
|
+
logger.exception(
|
|
1293
|
+
"Failed to query existing locks during shutdown"
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
logger.info(
|
|
1297
|
+
"Shutdown complete. Preserved %d lock(s), released %d lock(s).",
|
|
1298
|
+
n_kept,
|
|
1299
|
+
n_released,
|
|
1300
|
+
)
|
|
1301
|
+
except Exception:
|
|
1302
|
+
logger.exception("Error releasing locks during shutdown")
|
|
1303
|
+
for _attempt in range(3):
|
|
1304
|
+
try:
|
|
1305
|
+
if os.path.exists(PID_FILE):
|
|
1306
|
+
os.remove(PID_FILE)
|
|
1307
|
+
logger.info("Removed PID file: %s", PID_FILE)
|
|
1308
|
+
break
|
|
1309
|
+
except OSError as _e:
|
|
1310
|
+
if _attempt < 2:
|
|
1311
|
+
time.sleep(0.1)
|
|
1312
|
+
else:
|
|
1313
|
+
logger.warning("Could not remove PID file after 3 attempts: %s", _e)
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _write_pid_file(pid: int, parent_pid: int | None = None) -> None:
|
|
1317
|
+
"""Atomically write JSON metadata to the PID file for daemon-status checks.
|
|
1318
|
+
|
|
1319
|
+
Keeps process metadata to aid verification and diagnostics. Writes a JSON object
|
|
1320
|
+
containing pid, started_at (UTC ISO), cmdline and cwd.
|
|
1321
|
+
"""
|
|
1322
|
+
meta = {
|
|
1323
|
+
"pid": pid,
|
|
1324
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
1325
|
+
"entrypoint": "pycharm-watcher",
|
|
1326
|
+
"cmdline": " ".join([sys.executable] + sys.argv),
|
|
1327
|
+
"cwd": os.getcwd(),
|
|
1328
|
+
}
|
|
1329
|
+
if parent_pid:
|
|
1330
|
+
meta["parent_pid"] = parent_pid
|
|
1331
|
+
pid_dir = os.path.dirname(PID_FILE) or "."
|
|
1332
|
+
tmp = None
|
|
1333
|
+
try:
|
|
1334
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
1335
|
+
mode="w", delete=False, dir=pid_dir, suffix=".pid.tmp", encoding="utf-8"
|
|
1336
|
+
)
|
|
1337
|
+
tmp.write(json.dumps(meta))
|
|
1338
|
+
tmp.flush()
|
|
1339
|
+
tmp.close()
|
|
1340
|
+
os.replace(tmp.name, PID_FILE)
|
|
1341
|
+
logger.info("Wrote PID metadata to %s (PID: %d)", PID_FILE, pid)
|
|
1342
|
+
except Exception as exc:
|
|
1343
|
+
logger.warning("Failed to write PID metadata to %s: %s", PID_FILE, exc)
|
|
1344
|
+
if tmp is not None:
|
|
1345
|
+
try:
|
|
1346
|
+
os.unlink(tmp.name)
|
|
1347
|
+
except Exception as cleanup_exc:
|
|
1348
|
+
logger.debug("PID temp-file cleanup failed: %s", cleanup_exc)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _get_process_info_local(pid: int) -> tuple[str | None, int | None]:
|
|
1352
|
+
"""Fetch process name and parent PID via wmic on Windows."""
|
|
1353
|
+
if sys.platform != "win32":
|
|
1354
|
+
return None, None
|
|
1355
|
+
try:
|
|
1356
|
+
# Creationflags=0x08000000 hides the console window on Windows
|
|
1357
|
+
out = (
|
|
1358
|
+
subprocess.check_output(
|
|
1359
|
+
[
|
|
1360
|
+
"wmic",
|
|
1361
|
+
"process",
|
|
1362
|
+
"where",
|
|
1363
|
+
f"ProcessId={pid}",
|
|
1364
|
+
"get",
|
|
1365
|
+
"Name,ParentProcessId",
|
|
1366
|
+
],
|
|
1367
|
+
stderr=subprocess.DEVNULL,
|
|
1368
|
+
creationflags=0x08000000,
|
|
1369
|
+
)
|
|
1370
|
+
.decode()
|
|
1371
|
+
.strip()
|
|
1372
|
+
)
|
|
1373
|
+
lines = out.splitlines()
|
|
1374
|
+
if len(lines) > 1:
|
|
1375
|
+
parts = lines[1].split()
|
|
1376
|
+
# Parts usually [Name, ParentProcessId]
|
|
1377
|
+
if len(parts) >= 2:
|
|
1378
|
+
name = parts[0]
|
|
1379
|
+
ppid = int(parts[1])
|
|
1380
|
+
return name, ppid
|
|
1381
|
+
except Exception as exc:
|
|
1382
|
+
logger.debug("wmic process-info lookup for pid=%d failed: %s", pid, exc)
|
|
1383
|
+
return None, None
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _get_parent_ide_pid_local() -> int | None:
|
|
1387
|
+
"""Identify the process that owns this session.
|
|
1388
|
+
|
|
1389
|
+
Prioritizes walking up the process tree to find a known IDE window. Falls back to
|
|
1390
|
+
the direct parent shell (terminal) to ensure closure on tab/window exit.
|
|
1391
|
+
"""
|
|
1392
|
+
ide_names = {
|
|
1393
|
+
"antigravity.exe",
|
|
1394
|
+
"pycharm64.exe",
|
|
1395
|
+
"pycharm.exe",
|
|
1396
|
+
"code.exe",
|
|
1397
|
+
"idea64.exe",
|
|
1398
|
+
"idea.exe",
|
|
1399
|
+
"language_server_windows_x64.exe",
|
|
1400
|
+
"node.exe", # VSCode extension host
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
try:
|
|
1404
|
+
current_pid: Optional[int] = os.getpid()
|
|
1405
|
+
visited: set[int] = set()
|
|
1406
|
+
while current_pid and current_pid not in visited:
|
|
1407
|
+
visited.add(current_pid)
|
|
1408
|
+
# Type guard: current_pid is int here
|
|
1409
|
+
name, ppid = _get_process_info_local(current_pid)
|
|
1410
|
+
if name and name.lower() in ide_names:
|
|
1411
|
+
# Special case: if we found node.exe (VSCode extension host),
|
|
1412
|
+
# try to go up
|
|
1413
|
+
# to find the actual Code.exe window process.
|
|
1414
|
+
if name.lower() == "node.exe" and ppid:
|
|
1415
|
+
next_name, next_ppid = _get_process_info_local(ppid)
|
|
1416
|
+
if next_name and "code" in next_name.lower():
|
|
1417
|
+
logger.debug("Tying to VSCode IDE (PID: %d)", ppid)
|
|
1418
|
+
return ppid
|
|
1419
|
+
# Special case: if we found the terminal host, try to go up one
|
|
1420
|
+
# more to find the actual IDE window process.
|
|
1421
|
+
if name.lower() == "language_server_windows_x64.exe" and ppid:
|
|
1422
|
+
next_name, next_ppid = _get_process_info_local(ppid)
|
|
1423
|
+
if next_name and "antigravity" in next_name.lower():
|
|
1424
|
+
logger.debug("Tying to Antigravity IDE (PID: %d)", ppid)
|
|
1425
|
+
return ppid
|
|
1426
|
+
|
|
1427
|
+
logger.debug(
|
|
1428
|
+
"Tying to IDE via process name: %s (PID: %d)", name, current_pid
|
|
1429
|
+
)
|
|
1430
|
+
return current_pid
|
|
1431
|
+
|
|
1432
|
+
if not ppid or ppid == current_pid:
|
|
1433
|
+
break
|
|
1434
|
+
current_pid = ppid
|
|
1435
|
+
except Exception as e:
|
|
1436
|
+
logger.debug("Ancestor search failed: %s", e)
|
|
1437
|
+
|
|
1438
|
+
# Fallback 1: Environment Variables
|
|
1439
|
+
vspid = os.getenv("VSCODE_PID")
|
|
1440
|
+
if vspid and vspid.isdigit():
|
|
1441
|
+
vspid_int = int(vspid)
|
|
1442
|
+
if _is_process_alive(vspid_int):
|
|
1443
|
+
return vspid_int
|
|
1444
|
+
|
|
1445
|
+
if os.getenv("PYCHARM_HOSTED") == "1":
|
|
1446
|
+
return os.getppid()
|
|
1447
|
+
|
|
1448
|
+
# Fallback 2: Direct Parent Shell
|
|
1449
|
+
ppid = os.getppid()
|
|
1450
|
+
if ppid > 0:
|
|
1451
|
+
return ppid
|
|
1452
|
+
|
|
1453
|
+
return None
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _get_cmdline_for_pid_local(pid: int) -> Optional[str]:
|
|
1457
|
+
"""Local helper to fetch a process command-line (psutil preferred, then platform-
|
|
1458
|
+
specific fallbacks)."""
|
|
1459
|
+
try:
|
|
1460
|
+
import psutil
|
|
1461
|
+
|
|
1462
|
+
try:
|
|
1463
|
+
p = psutil.Process(pid)
|
|
1464
|
+
cmd = p.cmdline()
|
|
1465
|
+
if isinstance(cmd, (list, tuple)):
|
|
1466
|
+
return " ".join(cmd)
|
|
1467
|
+
return str(cmd)
|
|
1468
|
+
except Exception as exc:
|
|
1469
|
+
logger.debug("psutil.Process(%d).cmdline() failed: %s", pid, exc)
|
|
1470
|
+
except Exception:
|
|
1471
|
+
logger.debug("psutil not available for cmdline lookup (pid=%d)", pid)
|
|
1472
|
+
|
|
1473
|
+
# Windows fallbacks
|
|
1474
|
+
if sys.platform == "win32":
|
|
1475
|
+
try:
|
|
1476
|
+
out = subprocess.check_output(
|
|
1477
|
+
["wmic", "process", "where", f"ProcessId={pid}", "get", "CommandLine"],
|
|
1478
|
+
stderr=subprocess.DEVNULL,
|
|
1479
|
+
text=True,
|
|
1480
|
+
)
|
|
1481
|
+
lines = [line.strip() for line in out.splitlines() if line.strip()]
|
|
1482
|
+
if len(lines) >= 2:
|
|
1483
|
+
return " ".join(lines[1:]).strip()
|
|
1484
|
+
except Exception as exc:
|
|
1485
|
+
logger.debug("wmic cmdline lookup failed for pid=%d: %s", pid, exc)
|
|
1486
|
+
try:
|
|
1487
|
+
cmd_str = (
|
|
1488
|
+
'(Get-CimInstance Win32_Process -Filter "ProcessId=%d").'
|
|
1489
|
+
"CommandLine" % pid
|
|
1490
|
+
)
|
|
1491
|
+
ps_cmd = ("-NoProfile", "-Command", cmd_str)
|
|
1492
|
+
out = subprocess.check_output(
|
|
1493
|
+
["powershell", *ps_cmd], stderr=subprocess.DEVNULL, text=True
|
|
1494
|
+
)
|
|
1495
|
+
out = out.strip()
|
|
1496
|
+
if out:
|
|
1497
|
+
return out
|
|
1498
|
+
except Exception as exc:
|
|
1499
|
+
logger.debug("PowerShell cmdline lookup failed for pid=%d: %s", pid, exc)
|
|
1500
|
+
return None
|
|
1501
|
+
|
|
1502
|
+
return None
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _cmdline_matches_watcher_local(cmdline: Optional[str]) -> bool:
|
|
1506
|
+
if not cmdline:
|
|
1507
|
+
return False
|
|
1508
|
+
s = cmdline.lower()
|
|
1509
|
+
return (
|
|
1510
|
+
"live_locks_watcher" in s
|
|
1511
|
+
or "live_locks" in s
|
|
1512
|
+
or ("lock_client.py" in s and "watch" in s)
|
|
1513
|
+
or ("collab.core.lock_client" in s and "watch" in s)
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _shorten_process_label(
|
|
1518
|
+
label: Optional[str], max_tokens: int = 4, max_len: int = 80
|
|
1519
|
+
) -> Optional[str]:
|
|
1520
|
+
"""Return a short, human-friendly label for a process/entrypoint string.
|
|
1521
|
+
|
|
1522
|
+
- Collapse long filesystem paths to their basenames
|
|
1523
|
+
- Keep only the first `max_tokens` tokens and append ' ...' if truncated
|
|
1524
|
+
- Ensure the returned string is not longer than `max_len` (truncates with ellipsis)
|
|
1525
|
+
"""
|
|
1526
|
+
if not label:
|
|
1527
|
+
return None
|
|
1528
|
+
try:
|
|
1529
|
+
parts = label.split()
|
|
1530
|
+
short_parts: list[str] = []
|
|
1531
|
+
for p in parts[:max_tokens]:
|
|
1532
|
+
# If it's a path-like token, show only the basename for readability
|
|
1533
|
+
if ("/" in p) or ("\\" in p):
|
|
1534
|
+
try:
|
|
1535
|
+
b = os.path.basename(p)
|
|
1536
|
+
if b:
|
|
1537
|
+
short_parts.append(b)
|
|
1538
|
+
continue
|
|
1539
|
+
except Exception:
|
|
1540
|
+
pass
|
|
1541
|
+
# Normalize common python executable mention
|
|
1542
|
+
low = p.lower()
|
|
1543
|
+
if low.endswith("python") or low.endswith("python.exe") or "pythonw" in low:
|
|
1544
|
+
short_parts.append("python")
|
|
1545
|
+
else:
|
|
1546
|
+
short_parts.append(p)
|
|
1547
|
+
|
|
1548
|
+
short = " ".join(short_parts)
|
|
1549
|
+
if len(parts) > max_tokens:
|
|
1550
|
+
short = short + " ..."
|
|
1551
|
+
if len(short) > max_len:
|
|
1552
|
+
short = short[: max_len - 3].rstrip() + "..."
|
|
1553
|
+
return short
|
|
1554
|
+
except Exception:
|
|
1555
|
+
# Best-effort: return the original label if shortening fails
|
|
1556
|
+
return label if label else None
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def _existing_watcher_running() -> tuple[bool, int | None, str | None, str | None]:
|
|
1560
|
+
"""Check for an existing watcher process via PID file and return (is_running, pid,
|
|
1561
|
+
cmdline, entrypoint).
|
|
1562
|
+
|
|
1563
|
+
If no existing PID file or cannot verify, returns (False, None, None, None).
|
|
1564
|
+
"""
|
|
1565
|
+
try:
|
|
1566
|
+
if not os.path.exists(PID_FILE):
|
|
1567
|
+
return (False, None, None, None)
|
|
1568
|
+
with open(PID_FILE, "r", encoding="utf-8") as fh:
|
|
1569
|
+
raw = fh.read().strip()
|
|
1570
|
+
if not raw:
|
|
1571
|
+
return (False, None, None, None)
|
|
1572
|
+
pid = None
|
|
1573
|
+
cmdline = None
|
|
1574
|
+
entrypoint = None
|
|
1575
|
+
obj = None
|
|
1576
|
+
if raw.startswith("{"):
|
|
1577
|
+
try:
|
|
1578
|
+
obj = json.loads(raw)
|
|
1579
|
+
pid = obj.get("pid")
|
|
1580
|
+
cmdline = obj.get("cmdline")
|
|
1581
|
+
entrypoint = obj.get("entrypoint")
|
|
1582
|
+
except Exception:
|
|
1583
|
+
return (False, None, None, None)
|
|
1584
|
+
else:
|
|
1585
|
+
try:
|
|
1586
|
+
pid = int(raw)
|
|
1587
|
+
except Exception:
|
|
1588
|
+
return (False, None, None, None)
|
|
1589
|
+
|
|
1590
|
+
if not pid:
|
|
1591
|
+
return (False, None, None, None)
|
|
1592
|
+
|
|
1593
|
+
# If the PID file contains JSON metadata with a recorded cmdline or
|
|
1594
|
+
# entrypoint, prefer to verify via cmdline matching first. This allows
|
|
1595
|
+
# test suites to populate the metadata and stub `_get_cmdline_for_pid_local`
|
|
1596
|
+
# without requiring the test process to actually own the PID.
|
|
1597
|
+
if isinstance(obj, dict):
|
|
1598
|
+
try:
|
|
1599
|
+
real_cmd = _get_cmdline_for_pid_local(pid)
|
|
1600
|
+
if real_cmd:
|
|
1601
|
+
cmdline = real_cmd
|
|
1602
|
+
# If stored metadata or the resolved commandline looks like a
|
|
1603
|
+
# watcher, accept it as running (tests rely on this behavior).
|
|
1604
|
+
if _cmdline_matches_watcher_local(cmdline) or (
|
|
1605
|
+
entrypoint and _cmdline_matches_watcher_local(entrypoint)
|
|
1606
|
+
):
|
|
1607
|
+
return (True, pid, cmdline, entrypoint)
|
|
1608
|
+
except Exception as exc:
|
|
1609
|
+
logger.debug("Cmdline check for pid=%d failed: %s", pid, exc)
|
|
1610
|
+
|
|
1611
|
+
# Always verify the process is actually alive before trusting any cmdline data.
|
|
1612
|
+
if not _is_process_alive(pid):
|
|
1613
|
+
# Stale PID file — clean it up proactively so the next startup is fast.
|
|
1614
|
+
try:
|
|
1615
|
+
if os.path.exists(PID_FILE):
|
|
1616
|
+
if isinstance(obj, dict):
|
|
1617
|
+
stored_parent = obj.get("parent_pid")
|
|
1618
|
+
stored_entry = obj.get("entrypoint")
|
|
1619
|
+
started_at = obj.get("started_at")
|
|
1620
|
+
else:
|
|
1621
|
+
stored_parent = stored_entry = started_at = None
|
|
1622
|
+
|
|
1623
|
+
os.remove(PID_FILE)
|
|
1624
|
+
logger.warning(
|
|
1625
|
+
"Stale PID file detected: PID %d is no longer running. "
|
|
1626
|
+
"Removing stale file and starting fresh.",
|
|
1627
|
+
pid,
|
|
1628
|
+
)
|
|
1629
|
+
if stored_parent:
|
|
1630
|
+
parent_alive = _is_process_alive(stored_parent)
|
|
1631
|
+
logger.info(
|
|
1632
|
+
"Previous watcher details: parent_pid=%d (alive=%s), "
|
|
1633
|
+
"entrypoint=%s, started=%s",
|
|
1634
|
+
stored_parent,
|
|
1635
|
+
parent_alive,
|
|
1636
|
+
stored_entry or "unknown",
|
|
1637
|
+
started_at or "unknown",
|
|
1638
|
+
)
|
|
1639
|
+
if not parent_alive:
|
|
1640
|
+
logger.info(
|
|
1641
|
+
"Root cause: Parent IDE (PID %d) terminated. "
|
|
1642
|
+
"It did not clean up the watcher.",
|
|
1643
|
+
stored_parent,
|
|
1644
|
+
)
|
|
1645
|
+
except OSError:
|
|
1646
|
+
pass
|
|
1647
|
+
return (False, pid, None, None)
|
|
1648
|
+
|
|
1649
|
+
# Belt-and-suspenders: if the metadata records a parent_pid and that parent
|
|
1650
|
+
# is dead, the watcher is orphaned. Treat it as not running.
|
|
1651
|
+
if isinstance(obj, dict):
|
|
1652
|
+
stored_parent_pid = obj.get("parent_pid")
|
|
1653
|
+
if stored_parent_pid and not _is_process_alive(stored_parent_pid):
|
|
1654
|
+
logger.debug(
|
|
1655
|
+
"Watcher PID %d is alive but its parent PID %d is dead — "
|
|
1656
|
+
"treating as orphaned",
|
|
1657
|
+
pid,
|
|
1658
|
+
stored_parent_pid,
|
|
1659
|
+
)
|
|
1660
|
+
return (False, pid, cmdline, entrypoint)
|
|
1661
|
+
|
|
1662
|
+
real_cmd = _get_cmdline_for_pid_local(pid)
|
|
1663
|
+
if real_cmd:
|
|
1664
|
+
cmdline = real_cmd
|
|
1665
|
+
if _cmdline_matches_watcher_local(cmdline):
|
|
1666
|
+
return (True, pid, cmdline, entrypoint)
|
|
1667
|
+
return (False, pid, cmdline, entrypoint)
|
|
1668
|
+
except Exception:
|
|
1669
|
+
return (False, None, None, None)
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
# ---------------------------------------------------------------------------
|
|
1673
|
+
# Main Watcher Loop
|
|
1674
|
+
# ---------------------------------------------------------------------------
|
|
1675
|
+
def main() -> None:
|
|
1676
|
+
"""Run the PyCharm live lock watcher."""
|
|
1677
|
+
global DEVELOPER_ID
|
|
1678
|
+
|
|
1679
|
+
import argparse
|
|
1680
|
+
|
|
1681
|
+
parser = argparse.ArgumentParser(description="PyCharm Live Lock Watcher")
|
|
1682
|
+
parser.add_argument(
|
|
1683
|
+
"--interval", type=int, default=5, help="Poll interval (seconds)"
|
|
1684
|
+
)
|
|
1685
|
+
parser.add_argument(
|
|
1686
|
+
"--timeout",
|
|
1687
|
+
type=int,
|
|
1688
|
+
default=0,
|
|
1689
|
+
help="Idle timeout in minutes (0 = disabled)",
|
|
1690
|
+
)
|
|
1691
|
+
parser.add_argument(
|
|
1692
|
+
"--debug",
|
|
1693
|
+
action="store_true",
|
|
1694
|
+
help="Enable debug logging (prints heartbeat and debug details)",
|
|
1695
|
+
)
|
|
1696
|
+
parser.add_argument(
|
|
1697
|
+
"--parent-pid", type=int, help="Tie watcher lifecycle to this parent PID"
|
|
1698
|
+
)
|
|
1699
|
+
args = parser.parse_args()
|
|
1700
|
+
|
|
1701
|
+
if not SUPABASE_URL or not SUPABASE_ANON_KEY:
|
|
1702
|
+
logger.error(
|
|
1703
|
+
"Missing SUPABASE_URL or SUPABASE_ANON_KEY in .env.\n"
|
|
1704
|
+
"See .env.example for setup."
|
|
1705
|
+
)
|
|
1706
|
+
sys.exit(1)
|
|
1707
|
+
|
|
1708
|
+
# Normalize developer ID aggressively to avoid token divergence between IDEs
|
|
1709
|
+
DEVELOPER_ID = _get_developer_id().strip()
|
|
1710
|
+
|
|
1711
|
+
global SESSION_TOKEN
|
|
1712
|
+
SESSION_TOKEN = _get_session_token(DEVELOPER_ID)
|
|
1713
|
+
|
|
1714
|
+
# Log session token (truncated) for debugging cross-IDE token divergence
|
|
1715
|
+
logger.debug(
|
|
1716
|
+
"Session token: %s... (dev=%s, host=%s)",
|
|
1717
|
+
SESSION_TOKEN[:8],
|
|
1718
|
+
DEVELOPER_ID,
|
|
1719
|
+
socket.gethostname(),
|
|
1720
|
+
)
|
|
1721
|
+
|
|
1722
|
+
# Optional debug mode: enable verbose logging for diagnostics
|
|
1723
|
+
debug_mode = args.debug or os.getenv("COLLAB_DEBUG", "0").lower() in (
|
|
1724
|
+
"1",
|
|
1725
|
+
"true",
|
|
1726
|
+
"yes",
|
|
1727
|
+
)
|
|
1728
|
+
if debug_mode:
|
|
1729
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
1730
|
+
logger.setLevel(logging.DEBUG)
|
|
1731
|
+
logger.info("Debug logging enabled")
|
|
1732
|
+
|
|
1733
|
+
# Write PID file (unified with lock_client daemon)
|
|
1734
|
+
# Startup guard: avoid starting a second watcher if one is already active
|
|
1735
|
+
running, existing_pid, existing_cmd, existing_entry = _existing_watcher_running()
|
|
1736
|
+
if running:
|
|
1737
|
+
# When running under tests, the helper sets a test-local PID file
|
|
1738
|
+
# (named with prefix 'pytest_collab_'). In that case, avoid treating
|
|
1739
|
+
# the presence of the PID file as a real external watcher and allow
|
|
1740
|
+
# the test to drive main() behavior. This keeps test runs isolated
|
|
1741
|
+
# from developer machines that may have a real watcher running.
|
|
1742
|
+
if isinstance(PID_FILE, str) and "pytest_collab_" in PID_FILE:
|
|
1743
|
+
logger.debug(
|
|
1744
|
+
"Detected test-local PID file; ignoring existing-watcher guard"
|
|
1745
|
+
)
|
|
1746
|
+
else:
|
|
1747
|
+
# Prefer a stable human-facing label when the PID metadata contains an
|
|
1748
|
+
# entrypoint. Map well-known entrypoint tokens to a short, descriptive
|
|
1749
|
+
# process name so output is consistent for operators.
|
|
1750
|
+
label = None
|
|
1751
|
+
if existing_entry:
|
|
1752
|
+
e = str(existing_entry).lower()
|
|
1753
|
+
if e in ("lock-daemon", "lock-client"):
|
|
1754
|
+
# Prefer an explicit, familiar invocation instead of a short token
|
|
1755
|
+
label = "python lock_client.py"
|
|
1756
|
+
elif e == "pycharm-watcher":
|
|
1757
|
+
label = "python -m src.live_locks_watcher"
|
|
1758
|
+
else:
|
|
1759
|
+
label = _shorten_process_label(existing_entry)
|
|
1760
|
+
elif existing_cmd:
|
|
1761
|
+
label = _shorten_process_label(existing_cmd)
|
|
1762
|
+
|
|
1763
|
+
if label:
|
|
1764
|
+
first_line = f"Watcher already running (PID: {existing_pid}) — {label}."
|
|
1765
|
+
else:
|
|
1766
|
+
first_line = f"Watcher already running (PID: {existing_pid})."
|
|
1767
|
+
|
|
1768
|
+
# Use multi-line info so the IDE/terminal shows each action on its own line
|
|
1769
|
+
msg = (
|
|
1770
|
+
first_line
|
|
1771
|
+
+ "\nTo check status: collab daemon-status\n"
|
|
1772
|
+
+ "To stop: collab daemon-stop"
|
|
1773
|
+
)
|
|
1774
|
+
logger.info(msg)
|
|
1775
|
+
# Avoid printing a duplicate concise line to the console — the logger
|
|
1776
|
+
# output is sufficient and prevents double messages in IDE Run windows.
|
|
1777
|
+
sys.exit(0)
|
|
1778
|
+
|
|
1779
|
+
try:
|
|
1780
|
+
# Initialise parent PID from CLI, environment, or process tree
|
|
1781
|
+
parent_pid = args.parent_pid or _get_parent_ide_pid_local()
|
|
1782
|
+
|
|
1783
|
+
if args.parent_pid:
|
|
1784
|
+
logger.debug("Tied to parent PID via CLI argument: %d", parent_pid)
|
|
1785
|
+
elif parent_pid:
|
|
1786
|
+
logger.debug("Tied to parent PID via IDE detection: %d", parent_pid)
|
|
1787
|
+
else:
|
|
1788
|
+
logger.debug("No IDE owner identified. Running in persistent mode.")
|
|
1789
|
+
|
|
1790
|
+
# Record our PID and metadata so status checks work
|
|
1791
|
+
_write_pid_file(os.getpid(), parent_pid=parent_pid)
|
|
1792
|
+
except Exception:
|
|
1793
|
+
# Best-effort: if writing metadata fails, fall back to plain PID integer
|
|
1794
|
+
try:
|
|
1795
|
+
with open(PID_FILE, "w", encoding="utf-8") as f:
|
|
1796
|
+
f.write(str(os.getpid()))
|
|
1797
|
+
except OSError:
|
|
1798
|
+
pass
|
|
1799
|
+
|
|
1800
|
+
# Register cleanup
|
|
1801
|
+
if os.getenv("COLLAB_TEST_MODE") != "1":
|
|
1802
|
+
atexit.register(_graceful_shutdown)
|
|
1803
|
+
|
|
1804
|
+
def _signal_handler(signum, frame):
|
|
1805
|
+
logger.info("Received signal %d, shutting down...", signum)
|
|
1806
|
+
_graceful_shutdown()
|
|
1807
|
+
sys.exit(0)
|
|
1808
|
+
|
|
1809
|
+
if sys.platform != "win32":
|
|
1810
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
1811
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
1812
|
+
|
|
1813
|
+
# Create Supabase client
|
|
1814
|
+
if create_client is None:
|
|
1815
|
+
logger.error(
|
|
1816
|
+
"Supabase client factory is not available. Ensure supabase is installed."
|
|
1817
|
+
)
|
|
1818
|
+
sys.exit(1)
|
|
1819
|
+
# static analyzers may still treat create_client as Optional; cast for
|
|
1820
|
+
# their sake so they recognize the value is callable beyond this point.
|
|
1821
|
+
client = cast(Callable[..., Any], create_client)(SUPABASE_URL, SUPABASE_ANON_KEY)
|
|
1822
|
+
|
|
1823
|
+
# Start local dashboard server for a clickable URL
|
|
1824
|
+
dashboard_url = _start_dashboard_server()
|
|
1825
|
+
global _dashboard_url
|
|
1826
|
+
_dashboard_url = dashboard_url
|
|
1827
|
+
|
|
1828
|
+
logger.info("=" * 60)
|
|
1829
|
+
logger.info("Collab Locks -- PyCharm Watcher")
|
|
1830
|
+
logger.info("Developer: %s", DEVELOPER_ID)
|
|
1831
|
+
timeout_label = f"{args.timeout}m" if args.timeout > 0 else "disabled"
|
|
1832
|
+
logger.info("Interval: %ds | Timeout: %s", args.interval, timeout_label)
|
|
1833
|
+
if args.timeout > 0:
|
|
1834
|
+
logger.warning(
|
|
1835
|
+
"⚠️ --timeout is deprecated. With lock-persistence semantics,\n"
|
|
1836
|
+
" idle timeout means locks are kept alive with no active watcher.\n"
|
|
1837
|
+
" Consider removing --timeout to run the watcher indefinitely,\n"
|
|
1838
|
+
" or use `collab release-all` to manually clean up."
|
|
1839
|
+
)
|
|
1840
|
+
if dashboard_url:
|
|
1841
|
+
logger.info("Dashboard: %s", dashboard_url)
|
|
1842
|
+
else:
|
|
1843
|
+
logger.info("Dashboard: collab dashboard")
|
|
1844
|
+
logger.info("=" * 60)
|
|
1845
|
+
|
|
1846
|
+
last_modified: set = set()
|
|
1847
|
+
last_change_time = datetime.now()
|
|
1848
|
+
last_remote_scan = datetime.now()
|
|
1849
|
+
last_heartbeat = datetime.now()
|
|
1850
|
+
last_parent_check = datetime.now()
|
|
1851
|
+
|
|
1852
|
+
# Initial remote lock scan
|
|
1853
|
+
_scan_remote_locks(client)
|
|
1854
|
+
|
|
1855
|
+
# Startup reconciliation: sync Supabase lock state with local git
|
|
1856
|
+
_reconcile_on_startup(client)
|
|
1857
|
+
|
|
1858
|
+
# Initialize last_modified from current git state (post-reconciliation)
|
|
1859
|
+
# so the first polling iteration does not re-process already-locked files.
|
|
1860
|
+
try:
|
|
1861
|
+
last_modified = _run_git_status_porcelain()
|
|
1862
|
+
except Exception as exc:
|
|
1863
|
+
logger.warning(
|
|
1864
|
+
"Initial git-status snapshot failed — "
|
|
1865
|
+
"first poll may lock unexpected files: %s",
|
|
1866
|
+
exc,
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1869
|
+
try:
|
|
1870
|
+
while True:
|
|
1871
|
+
# Remote lock scan every 30 seconds (independent of git status)
|
|
1872
|
+
now = datetime.now()
|
|
1873
|
+
# Periodic heartbeat (helps diagnose silent exits)
|
|
1874
|
+
if (now - last_heartbeat).total_seconds() > 60:
|
|
1875
|
+
last_heartbeat = now
|
|
1876
|
+
logger.debug("heartbeat pid=%d", os.getpid())
|
|
1877
|
+
|
|
1878
|
+
# Parent process liveness check every 5 seconds (snappy termination)
|
|
1879
|
+
if parent_pid and (now - last_parent_check).total_seconds() > 5:
|
|
1880
|
+
last_parent_check = now
|
|
1881
|
+
if not _is_process_alive(parent_pid):
|
|
1882
|
+
logger.info(
|
|
1883
|
+
"Parent process (PID: %d) is dead. Shutting down...", parent_pid
|
|
1884
|
+
)
|
|
1885
|
+
break
|
|
1886
|
+
|
|
1887
|
+
if (now - last_remote_scan).total_seconds() > 30:
|
|
1888
|
+
last_remote_scan = now
|
|
1889
|
+
_scan_remote_locks(client)
|
|
1890
|
+
|
|
1891
|
+
# Get files that are in progress (dirty OR committed-but-unpushed)
|
|
1892
|
+
try:
|
|
1893
|
+
current_modified = _run_git_status_porcelain()
|
|
1894
|
+
except Exception as e:
|
|
1895
|
+
logger.error("Failed to get modified files: %s", e)
|
|
1896
|
+
time.sleep(args.interval)
|
|
1897
|
+
continue
|
|
1898
|
+
|
|
1899
|
+
if current_modified != last_modified:
|
|
1900
|
+
last_change_time = datetime.now()
|
|
1901
|
+
branch = _get_current_branch()
|
|
1902
|
+
|
|
1903
|
+
# New files to lock
|
|
1904
|
+
new_files = current_modified - last_modified
|
|
1905
|
+
# Delegate acquire/release logic to helper functions to allow
|
|
1906
|
+
# targeted unit tests to exercise error/fallback branches.
|
|
1907
|
+
_process_new_files(client, branch, new_files)
|
|
1908
|
+
|
|
1909
|
+
# Files no longer modified locally
|
|
1910
|
+
released = last_modified - current_modified
|
|
1911
|
+
_process_releases(client, released)
|
|
1912
|
+
|
|
1913
|
+
last_modified = current_modified
|
|
1914
|
+
else:
|
|
1915
|
+
# Idle timeout check
|
|
1916
|
+
idle = datetime.now() - last_change_time
|
|
1917
|
+
if args.timeout > 0 and idle > timedelta(minutes=args.timeout):
|
|
1918
|
+
# Check which locks will be preserved
|
|
1919
|
+
# (dirty OR committed-but-unpushed)
|
|
1920
|
+
try:
|
|
1921
|
+
still_dirty = _run_git_status_porcelain()
|
|
1922
|
+
kept_locks = _local_owned_locks & still_dirty
|
|
1923
|
+
except Exception:
|
|
1924
|
+
kept_locks = set(_local_owned_locks)
|
|
1925
|
+
if kept_locks:
|
|
1926
|
+
logger.warning(
|
|
1927
|
+
"⚠️ IDLE TIMEOUT REACHED (%dm of inactivity)\n"
|
|
1928
|
+
" The watcher is stopping, but %d lock(s) are "
|
|
1929
|
+
"being PRESERVED in Supabase\n"
|
|
1930
|
+
" because the following files still have local "
|
|
1931
|
+
"edits:\n%s\n"
|
|
1932
|
+
" These files will remain locked until the "
|
|
1933
|
+
"watcher is restarted.\n"
|
|
1934
|
+
" Restart with: python -m src.live_locks_watcher",
|
|
1935
|
+
args.timeout,
|
|
1936
|
+
len(kept_locks),
|
|
1937
|
+
"\n".join(f" - {f}" for f in sorted(kept_locks)),
|
|
1938
|
+
)
|
|
1939
|
+
for kf in kept_locks:
|
|
1940
|
+
_notify(
|
|
1941
|
+
"Watcher idle timeout",
|
|
1942
|
+
f"{kf} lock preserved",
|
|
1943
|
+
)
|
|
1944
|
+
else:
|
|
1945
|
+
logger.info(
|
|
1946
|
+
"Timed out after %dm inactivity.",
|
|
1947
|
+
args.timeout,
|
|
1948
|
+
)
|
|
1949
|
+
break
|
|
1950
|
+
|
|
1951
|
+
time.sleep(args.interval)
|
|
1952
|
+
|
|
1953
|
+
except KeyboardInterrupt:
|
|
1954
|
+
logger.info("Stopped by user.")
|
|
1955
|
+
except Exception as e:
|
|
1956
|
+
logger.error("Watcher loop error: %s", e, exc_info=True)
|
|
1957
|
+
_notify("Watcher Error", f"Loop error: {e}")
|
|
1958
|
+
finally:
|
|
1959
|
+
_graceful_shutdown()
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
if __name__ == "__main__":
|
|
1963
|
+
try:
|
|
1964
|
+
main()
|
|
1965
|
+
except Exception as exc: # top-level catch to ensure operator sees failures
|
|
1966
|
+
tb = traceback.format_exc()
|
|
1967
|
+
# Log to the standard logs/ directory via the structured logger.
|
|
1968
|
+
logger.error("Unhandled exception in live_locks_watcher: %s\n%s", exc, tb)
|
|
1969
|
+
|
|
1970
|
+
# Print short, operator-friendly message to stderr so it appears in
|
|
1971
|
+
# the IDE/terminal immediately, pointing to the full log for details.
|
|
1972
|
+
print(
|
|
1973
|
+
"Unhandled error in watcher. See logs/collab.log",
|
|
1974
|
+
file=sys.stderr,
|
|
1975
|
+
)
|
|
1976
|
+
|
|
1977
|
+
# Attempt graceful cleanup, then exit non-zero
|
|
1978
|
+
try:
|
|
1979
|
+
_graceful_shutdown()
|
|
1980
|
+
except Exception as cleanup_exc:
|
|
1981
|
+
logger.warning("Graceful-shutdown fallback failed: %s", cleanup_exc)
|
|
1982
|
+
sys.exit(1)
|