delimit-cli 4.1.43 → 4.1.44

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.
@@ -1,2 +1,190 @@
1
- # supabase_sync Pro module (stubbed in npm package)
2
- # Full implementation available on delimit.ai server
1
+ """Supabase sync -- writes gateway data to cloud for dashboard access.
2
+
3
+ Writes are fire-and-forget (never blocks tool execution).
4
+ If Supabase is unreachable, data stays in local files (always the source of truth).
5
+ """
6
+ import json
7
+ import os
8
+ import logging
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Dict, Optional
12
+
13
+ logger = logging.getLogger("delimit.supabase_sync")
14
+
15
+ _client = None
16
+ _init_attempted = False
17
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
18
+ SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
19
+
20
+ # Also check local secrets file
21
+ if not SUPABASE_URL:
22
+ secrets_file = Path.home() / ".delimit" / "secrets" / "supabase.json"
23
+ if secrets_file.exists():
24
+ try:
25
+ creds = json.loads(secrets_file.read_text())
26
+ SUPABASE_URL = creds.get("url", "")
27
+ SUPABASE_KEY = creds.get("service_role_key", "")
28
+ except Exception:
29
+ pass
30
+
31
+
32
+ def _get_client():
33
+ """Lazy-init Supabase client. Returns the SDK client, 'http' for fallback, or None."""
34
+ global _client, _init_attempted
35
+ if _client is not None:
36
+ return _client
37
+ if _init_attempted:
38
+ return _client # Already tried and failed, return cached result (may be None or "http")
39
+ _init_attempted = True
40
+ if not SUPABASE_URL or not SUPABASE_KEY:
41
+ return None
42
+ try:
43
+ from supabase import create_client
44
+ _client = create_client(SUPABASE_URL, SUPABASE_KEY)
45
+ return _client
46
+ except ImportError:
47
+ logger.debug("supabase-py not installed, using HTTP fallback")
48
+ _client = "http"
49
+ return _client
50
+ except Exception as e:
51
+ logger.warning(f"Supabase init failed: {e}")
52
+ _client = "http" # Fall back to HTTP rather than giving up entirely
53
+ return _client
54
+
55
+
56
+ def _http_post(table: str, data: dict, headers_extra: Optional[Dict] = None) -> bool:
57
+ """POST to Supabase REST API without the SDK."""
58
+ import urllib.request
59
+ try:
60
+ url = f"{SUPABASE_URL}/rest/v1/{table}"
61
+ body = json.dumps(data).encode()
62
+ req = urllib.request.Request(url, data=body, method="POST")
63
+ req.add_header("Content-Type", "application/json")
64
+ req.add_header("apikey", SUPABASE_KEY)
65
+ req.add_header("Authorization", f"Bearer {SUPABASE_KEY}")
66
+ req.add_header("Prefer", "return=minimal")
67
+ if headers_extra:
68
+ for k, v in headers_extra.items():
69
+ req.add_header(k, v)
70
+ urllib.request.urlopen(req, timeout=5)
71
+ return True
72
+ except Exception as e:
73
+ logger.debug(f"Supabase HTTP POST to {table} failed: {e}")
74
+ return False
75
+
76
+
77
+ def _http_patch(table: str, query: str, data: dict) -> bool:
78
+ """PATCH to Supabase REST API without the SDK."""
79
+ import urllib.request
80
+ try:
81
+ url = f"{SUPABASE_URL}/rest/v1/{table}?{query}"
82
+ body = json.dumps(data).encode()
83
+ req = urllib.request.Request(url, data=body, method="PATCH")
84
+ req.add_header("Content-Type", "application/json")
85
+ req.add_header("apikey", SUPABASE_KEY)
86
+ req.add_header("Authorization", f"Bearer {SUPABASE_KEY}")
87
+ req.add_header("Prefer", "return=minimal")
88
+ urllib.request.urlopen(req, timeout=5)
89
+ return True
90
+ except Exception as e:
91
+ logger.debug(f"Supabase HTTP PATCH to {table} failed: {e}")
92
+ return False
93
+
94
+
95
+ def sync_event(event: dict):
96
+ """Sync an event to Supabase (fire-and-forget).
97
+
98
+ Maps the gateway event dict to the Supabase events table schema:
99
+ id (uuid, required), type (text, required), tool (text, required),
100
+ ts, model, status, venture, detail, user_id, session_id
101
+ """
102
+ try:
103
+ client = _get_client()
104
+ if client is None:
105
+ return
106
+ row = {
107
+ "id": str(uuid.uuid4()),
108
+ "type": event.get("type", "tool_call"),
109
+ "tool": event.get("tool", "unknown"),
110
+ "ts": event.get("ts", ""),
111
+ "model": event.get("model", ""),
112
+ "status": event.get("status", "ok"),
113
+ "venture": event.get("venture", ""),
114
+ "session_id": event.get("session_id", ""),
115
+ "user_id": event.get("user_id", ""),
116
+ }
117
+ # Include risk_level and trace info in detail field
118
+ detail_parts = []
119
+ if event.get("risk_level"):
120
+ detail_parts.append(f"risk={event['risk_level']}")
121
+ if event.get("trace_id"):
122
+ detail_parts.append(f"trace={event['trace_id']}")
123
+ if event.get("span_id"):
124
+ detail_parts.append(f"span={event['span_id']}")
125
+ if detail_parts:
126
+ row["detail"] = " ".join(detail_parts)
127
+
128
+ if client == "http":
129
+ _http_post("events", row)
130
+ else:
131
+ client.table("events").insert(row).execute()
132
+ except Exception as e:
133
+ logger.debug(f"Event sync failed: {e}")
134
+
135
+
136
+ def sync_ledger_item(item: dict):
137
+ """Sync a ledger item to Supabase (upsert).
138
+
139
+ Maps the gateway ledger item to the Supabase ledger_items table schema:
140
+ id (text, required), title (text, required), priority, venture,
141
+ status, description, source, note, assignee
142
+ """
143
+ try:
144
+ client = _get_client()
145
+ if client is None:
146
+ return
147
+ row = {
148
+ "id": item.get("id", ""),
149
+ "title": item.get("title", ""),
150
+ "priority": item.get("priority", "P1"),
151
+ "venture": item.get("venture", ""),
152
+ "status": item.get("status", "open"),
153
+ "description": item.get("description", ""),
154
+ "source": item.get("source", "mcp"),
155
+ }
156
+ if not row["id"] or not row["title"]:
157
+ return # Required fields missing
158
+ if client == "http":
159
+ _http_post(
160
+ "ledger_items",
161
+ row,
162
+ headers_extra={
163
+ "Prefer": "resolution=merge-duplicates,return=minimal",
164
+ },
165
+ )
166
+ else:
167
+ client.table("ledger_items").upsert(row).execute()
168
+ except Exception as e:
169
+ logger.debug(f"Ledger item sync failed: {e}")
170
+
171
+
172
+ def sync_ledger_update(item_id: str, status: str, note: str = ""):
173
+ """Sync a ledger status update to Supabase."""
174
+ try:
175
+ client = _get_client()
176
+ if client is None:
177
+ return
178
+ update = {"status": status}
179
+ if note:
180
+ update["note"] = note
181
+ if status == "done":
182
+ from datetime import datetime, timezone
183
+ update["completed_at"] = datetime.now(timezone.utc).isoformat()
184
+
185
+ if client == "http":
186
+ _http_patch("ledger_items", f"id=eq.{item_id}", update)
187
+ else:
188
+ client.table("ledger_items").update(update).eq("id", item_id).execute()
189
+ except Exception as e:
190
+ logger.debug(f"Ledger update sync failed: {e}")