collab-runtime 0.3.0__tar.gz → 0.4.0__tar.gz

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 (25) hide show
  1. {collab_runtime-0.3.0/collab_runtime.egg-info → collab_runtime-0.4.0}/PKG-INFO +33 -2
  2. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/README.md +29 -6
  3. collab_runtime-0.4.0/collab/agent_identity.py +293 -0
  4. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/dashboard/index.html +152 -41
  5. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/dashboard_server.py +87 -8
  6. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/live_locks_watcher.py +163 -91
  7. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/lock_client.py +242 -118
  8. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/main.py +75 -7
  9. {collab_runtime-0.3.0 → collab_runtime-0.4.0/collab_runtime.egg-info}/PKG-INFO +33 -2
  10. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab_runtime.egg-info/SOURCES.txt +1 -0
  11. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/docs/pypi/README.md +32 -1
  12. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/pyproject.toml +1 -1
  13. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/LICENSE +0 -0
  14. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/__init__.py +0 -0
  15. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/__main__.py +0 -0
  16. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/errors.py +0 -0
  17. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/logging_config.py +0 -0
  18. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/platform_probe.py +0 -0
  19. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/safe_subprocess.py +0 -0
  20. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab/subprocess_bridge.py +0 -0
  21. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab_runtime.egg-info/dependency_links.txt +0 -0
  22. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab_runtime.egg-info/entry_points.txt +0 -0
  23. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab_runtime.egg-info/requires.txt +0 -0
  24. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/collab_runtime.egg-info/top_level.txt +0 -0
  25. {collab_runtime-0.3.0 → collab_runtime-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -68,7 +68,7 @@ pip install collab-runtime
68
68
  For a minimum-version install (ensures you get the latest compatible release):
69
69
 
70
70
  ```bash
71
- pip install "collab-runtime>=0.2.2"
71
+ pip install "collab-runtime>=0.3.2"
72
72
  ```
73
73
 
74
74
  ---
@@ -91,10 +91,37 @@ SUPABASE_ANON_KEY=your_anon_key_here
91
91
  SUPABASE_SERVICE_ROLE_KEY=your_service_role_key # Required for force-release
92
92
  DEVELOPER_ID=your_name # Optional, defaults to git user.name
93
93
  LOCK_STRICT=0 # 1 = block on lock errors, 0 = warn only
94
+ COLLAB_AGENT_ID=agent-my-task # Optional: unique id per AI agent session
95
+ COLLAB_AGENT_LABEL=refactor-auth # Optional display label
96
+ COLLAB_AGENT_MODE=1 # Auto-generate/persist agent id when unset
94
97
  ```
95
98
 
96
99
  > **Keep `SUPABASE_SERVICE_ROLE_KEY` private — never commit it to version control.**
97
100
 
101
+ ### Multi-agent usage (same GitHub user, multiple AI agents)
102
+
103
+ When one developer runs several AI agents in the same repo, give each agent its own identity so
104
+ locks do not collide (effective owner is `developer_id` + `agent_id`):
105
+
106
+ ```bash
107
+ # Agent A
108
+ export COLLAB_AGENT_ID=agent-refactor-auth
109
+ collab whoami
110
+ collab acquire src/auth.py --reason "Refactor auth"
111
+
112
+ # Agent B (different id) — conflicts with A on the same file
113
+ export COLLAB_AGENT_ID=agent-fix-tests
114
+ collab acquire src/auth.py
115
+ ```
116
+
117
+ CLI flags `--agent-id` and `--agent-label` override env vars. Use `collab active --mine` to list
118
+ only locks held by the current human + agent pair.
119
+
120
+ **Existing Supabase projects:** apply the `agent_id` / `agent_label` columns and updated
121
+ `acquire_lock` function from
122
+ [`supabase/schema.sql`](https://github.com/KirilMT/collab/blob/main/supabase/schema.sql) in the SQL
123
+ Editor (fresh installs already include them).
124
+
98
125
  ### 3 — Verify Connection
99
126
 
100
127
  ```bash
@@ -108,8 +135,12 @@ If connected, this lists all currently active locks (empty on a fresh setup).
108
135
  ## CLI Reference
109
136
 
110
137
  ```bash
138
+ # Show resolved developer and agent identity
139
+ collab whoami
140
+
111
141
  # Show all active locks across the team
112
142
  collab active
143
+ collab active --mine
113
144
 
114
145
  # Lock a file before editing
115
146
  collab acquire path/to/file.py --reason "Implementing feature X"
@@ -74,15 +74,38 @@ The setup script automatically:
74
74
 
75
75
  After setup, verify your `.env` at the project root has these values:
76
76
 
77
- | Variable | Description |
78
- | --------------------------- | --------------------------------------------------------------- |
79
- | `SUPABASE_URL` | Your Supabase project URL (from Project Settings → API) |
80
- | `SUPABASE_ANON_KEY` | Anonymous/public key (from Project Settings → API) |
81
- | `SUPABASE_SERVICE_ROLE_KEY` | Service role key (**required** for dashboard force-release) |
82
- | `LOCK_STRICT` | If `1`, git hooks block on lock errors. Default `0` (warn only) |
77
+ | Variable | Description |
78
+ | --------------------------- | ---------------------------------------------------------------- |
79
+ | `SUPABASE_URL` | Your Supabase project URL (from Project Settings → API) |
80
+ | `SUPABASE_ANON_KEY` | Anonymous/public key (from Project Settings → API) |
81
+ | `SUPABASE_SERVICE_ROLE_KEY` | Service role key (**required** for dashboard force-release) |
82
+ | `LOCK_STRICT` | If `1`, git hooks block on lock errors. Default `0` (warn only) |
83
+ | `COLLAB_AGENT_ID` | Optional stable id for an AI agent session (multi-agent locking) |
84
+ | `COLLAB_AGENT_LABEL` | Optional display label (e.g. `refactor-auth`) |
85
+ | `COLLAB_AGENT_MODE` | Set to `1` to auto-generate/persist an agent id when unset |
83
86
 
84
87
  > **Important:** `SUPABASE_SERVICE_ROLE_KEY` is needed for the dashboard's Force Release button. Without it, only your own locks can be released.
85
88
 
89
+ ### Multi-agent usage (same GitHub user, multiple AI agents)
90
+
91
+ When one developer runs several AI agents in the same repo, give each agent its own identity so
92
+ locks do not collide:
93
+
94
+ ```bash
95
+ # Terminal / agent A
96
+ set COLLAB_AGENT_ID=agent-refactor-auth
97
+ collab whoami
98
+ collab acquire src/auth.py --reason "Refactor auth"
99
+
100
+ # Terminal / agent B (different id)
101
+ set COLLAB_AGENT_ID=agent-fix-tests
102
+ collab acquire src/auth.py # conflict — locked by agent-refactor-auth
103
+ ```
104
+
105
+ For existing Supabase projects, re-run the `acquire_lock` function and add the `agent_id` /
106
+ `agent_label` columns from the updated [supabase/schema.sql](supabase/schema.sql) (fresh installs
107
+ already include them).
108
+
86
109
  ### 4. Verify Setup
87
110
 
88
111
  After setup, verify the connection:
@@ -0,0 +1,293 @@
1
+ """Agent identity resolution for multi-agent collaborative locking.
2
+
3
+ When one GitHub user runs multiple AI agents in the same repository, each agent needs a
4
+ stable, unique identity layered on top of the human ``developer_id``. This module
5
+ resolves ``agent_id`` / ``agent_label`` and provides helpers for lock ownership
6
+ comparisons.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ import re
14
+ import uuid
15
+ from typing import Any, Optional
16
+
17
+ _AGENT_ID_FILE = ".agent_id"
18
+ _AGENT_LABEL_FILE = ".agent_label"
19
+ _AGENT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$")
20
+
21
+ # Environment variables that imply an AI agent runtime (not exhaustive).
22
+ _AGENT_RUNTIME_MARKERS: tuple[tuple[str, str], ...] = (
23
+ ("CURSOR_TRACE_ID", "cursor"),
24
+ ("CURSOR_SESSION_ID", "cursor"),
25
+ ("CURSOR_AGENT", "cursor"),
26
+ ("COMPOSER_SESSION_ID", "composer"),
27
+ ("CLAUDE_CODE", "claude-code"),
28
+ ("CLAUDE_CODE_SESSION", "claude-code"),
29
+ ("GITHUB_COPILOT_AGENT_ID", "copilot"),
30
+ )
31
+
32
+
33
+ def _read_clean_env(name: str) -> Optional[str]:
34
+ raw = os.getenv(name)
35
+ if raw is None:
36
+ return None
37
+ val = raw.strip()
38
+ if not val or val.startswith("#"):
39
+ return None
40
+ if "#" in val:
41
+ val = val.split("#", 1)[0].strip()
42
+ return val or None
43
+
44
+
45
+ def _is_truthy_env(name: str) -> bool:
46
+ raw = _read_clean_env(name)
47
+ if raw is None:
48
+ return False
49
+ return raw.lower() in {"1", "true", "yes", "on"}
50
+
51
+
52
+ def detect_agent_runtime_label() -> Optional[str]:
53
+ """Return a friendly runtime label when known agent env markers are present."""
54
+ for env_name, label in _AGENT_RUNTIME_MARKERS:
55
+ if _read_clean_env(env_name):
56
+ return label
57
+ return None
58
+
59
+
60
+ def is_agent_mode_requested() -> bool:
61
+ """Return True when agent identity should be active for this process."""
62
+ if _read_clean_env("COLLAB_AGENT_ID"):
63
+ return True
64
+ if _is_truthy_env("COLLAB_AGENT_MODE"):
65
+ return True
66
+ if detect_agent_runtime_label():
67
+ return True
68
+ return False
69
+
70
+
71
+ def _sanitize_agent_id(value: str) -> Optional[str]:
72
+ candidate = value.strip()
73
+ if not candidate or not _AGENT_ID_PATTERN.match(candidate):
74
+ return None
75
+ return candidate
76
+
77
+
78
+ def _agent_id_file(state_dir: str) -> str:
79
+ return os.path.join(state_dir, _AGENT_ID_FILE)
80
+
81
+
82
+ def load_persisted_agent_id(state_dir: str) -> Optional[str]:
83
+ """Load a previously persisted agent id from the collab state directory."""
84
+ path = _agent_id_file(state_dir)
85
+ try:
86
+ if not os.path.isfile(path):
87
+ return None
88
+ with open(path, encoding="utf-8") as fh:
89
+ raw = fh.read().strip()
90
+ return _sanitize_agent_id(raw) if raw else None
91
+ except OSError:
92
+ return None
93
+
94
+
95
+ def persist_agent_id(state_dir: str, agent_id: str) -> None:
96
+ """Persist agent id so subsequent invocations in this state dir reuse it."""
97
+ os.makedirs(state_dir, exist_ok=True)
98
+ path = _agent_id_file(state_dir)
99
+ tmp = f"{path}.tmp"
100
+ with open(tmp, "w", encoding="utf-8") as fh:
101
+ fh.write(agent_id)
102
+ fh.flush()
103
+ try:
104
+ os.fsync(fh.fileno())
105
+ except OSError:
106
+ pass
107
+ os.replace(tmp, path)
108
+
109
+
110
+ def generate_agent_id() -> str:
111
+ """Generate a stable-format unique agent id."""
112
+ return f"agent-{uuid.uuid4().hex[:8]}"
113
+
114
+
115
+ def resolve_agent_id(
116
+ state_dir: str,
117
+ *,
118
+ explicit_agent_id: Optional[str] = None,
119
+ agent_mode: Optional[bool] = None,
120
+ ) -> Optional[str]:
121
+ """Resolve agent id using precedence: explicit env/arg → persisted → generated.
122
+
123
+ Returns ``None`` when agent mode is off (human-only locking).
124
+ """
125
+ mode = is_agent_mode_requested() if agent_mode is None else agent_mode
126
+ if explicit_agent_id or _read_clean_env("COLLAB_AGENT_ID"):
127
+ mode = True
128
+ if not mode:
129
+ return None
130
+
131
+ for candidate in (
132
+ explicit_agent_id,
133
+ _read_clean_env("COLLAB_AGENT_ID"),
134
+ ):
135
+ if candidate:
136
+ sanitized = _sanitize_agent_id(candidate)
137
+ if sanitized:
138
+ persist_agent_id(state_dir, sanitized)
139
+ return sanitized
140
+
141
+ persisted = load_persisted_agent_id(state_dir)
142
+ if persisted:
143
+ return persisted
144
+
145
+ generated = generate_agent_id()
146
+ persist_agent_id(state_dir, generated)
147
+ return generated
148
+
149
+
150
+ def resolve_agent_label(
151
+ *,
152
+ explicit_label: Optional[str] = None,
153
+ runtime_label: Optional[str] = None,
154
+ ) -> Optional[str]:
155
+ """Resolve optional human-readable agent label."""
156
+ for candidate in (
157
+ explicit_label,
158
+ _read_clean_env("COLLAB_AGENT_LABEL"),
159
+ runtime_label,
160
+ detect_agent_runtime_label(),
161
+ ):
162
+ if candidate:
163
+ val = candidate.strip()
164
+ if val:
165
+ return val[:256]
166
+ return None
167
+
168
+
169
+ def agent_ids_match(
170
+ lock_agent_id: Optional[str],
171
+ client_agent_id: Optional[str],
172
+ ) -> bool:
173
+ """Return True when two agent_id values represent the same lock owner."""
174
+ return (lock_agent_id or None) == (client_agent_id or None)
175
+
176
+
177
+ def lock_owned_by_client(
178
+ lock: dict[str, Any],
179
+ developer_id: str,
180
+ agent_id: Optional[str],
181
+ ) -> bool:
182
+ """Return True when *lock* belongs to the given human + agent pair."""
183
+ if lock.get("developer_id") != developer_id:
184
+ return False
185
+ return agent_ids_match(lock.get("agent_id"), agent_id)
186
+
187
+
188
+ def format_lock_owner(
189
+ developer_id: str,
190
+ agent_id: Optional[str] = None,
191
+ agent_label: Optional[str] = None,
192
+ ) -> str:
193
+ """Format lock owner for CLI/log messages."""
194
+ base = f"@{developer_id}"
195
+ if agent_id:
196
+ label = agent_label or agent_id
197
+ return f"{base} (agent: {label})"
198
+ return base
199
+
200
+
201
+ def format_conflict_message(
202
+ file_path: str,
203
+ developer_id: str,
204
+ agent_id: Optional[str] = None,
205
+ agent_label: Optional[str] = None,
206
+ ) -> str:
207
+ """Build a user-facing conflict message."""
208
+ owner = format_lock_owner(developer_id, agent_id, agent_label)
209
+ return f"⚠ {file_path} is locked by {owner}. Editing is not recommended."
210
+
211
+
212
+ def session_token_seed(
213
+ developer_id: str,
214
+ agent_id: Optional[str],
215
+ hostname: str,
216
+ project_root: str,
217
+ ) -> str:
218
+ """Build the seed string for deterministic session tokens.
219
+
220
+ Each component derivation is defensive: if a value cannot be stringified, a safe
221
+ fallback is used rather than raising (a raised seed breaks re-adoption).
222
+ """
223
+ try:
224
+ dev = str(developer_id).strip().lower() if developer_id else "unknown"
225
+ except Exception:
226
+ dev = "unknown"
227
+ try:
228
+ host = hostname.lower() if hostname else "localhost"
229
+ except Exception:
230
+ host = "localhost"
231
+ try:
232
+ root = project_root.lower().rstrip("\\/") if project_root else "project"
233
+ except Exception:
234
+ root = "project"
235
+ if agent_id:
236
+ try:
237
+ agent = str(agent_id).strip().lower()
238
+ except Exception:
239
+ agent = "agent"
240
+ return f"{dev}:{agent}:{host}:{root}"
241
+ return f"{dev}:{host}:{root}"
242
+
243
+
244
+ def session_token_from_seed(seed: str) -> str:
245
+ """Derive the 16-char hex session token from a seed."""
246
+ return hashlib.sha256(seed.encode()).hexdigest()[:16]
247
+
248
+
249
+ def daemon_pid_basename(agent_id: Optional[str]) -> str:
250
+ """Return the daemon PID filename for the given agent (or default)."""
251
+ if not agent_id:
252
+ return ".daemon.pid"
253
+ safe = re.sub(r"[^a-zA-Z0-9._-]", "_", agent_id)
254
+ return f".daemon.{safe}.pid"
255
+
256
+
257
+ def resolve_daemon_pid_path(
258
+ state_dir: str,
259
+ agent_id: Optional[str],
260
+ *,
261
+ env_override: Optional[str] = None,
262
+ ) -> str:
263
+ """Resolve the PID file path for a watcher instance."""
264
+ override = env_override or _read_clean_env("COLLAB_PID_FILE")
265
+ if override:
266
+ return override
267
+ return os.path.join(state_dir, daemon_pid_basename(agent_id))
268
+
269
+
270
+ def apply_agent_filter(query: Any, agent_id: Optional[str]) -> Any:
271
+ """Scope a PostgREST delete/update query to the current agent_id (or NULL)."""
272
+ if agent_id is None:
273
+ is_null = getattr(query, "is_", None)
274
+ if callable(is_null):
275
+ return is_null("agent_id", "null")
276
+ # Test doubles may only implement ``eq`` — treat NULL as None there.
277
+ return query.eq("agent_id", None)
278
+ return query.eq("agent_id", agent_id)
279
+
280
+
281
+ def identity_summary(
282
+ developer_id: str,
283
+ agent_id: Optional[str],
284
+ agent_label: Optional[str],
285
+ ) -> dict[str, Optional[str]]:
286
+ """Return a dict suitable for ``collab whoami`` JSON output."""
287
+ mode = "agent" if agent_id else "human"
288
+ return {
289
+ "developer_id": developer_id,
290
+ "agent_id": agent_id,
291
+ "agent_label": agent_label,
292
+ "mode": mode,
293
+ }