collab-runtime 0.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. tests/packaging/test_smoke_install.py +76 -0
@@ -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)