hoja-cli 0.21.4__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 (107) hide show
  1. VERSION +1 -0
  2. google_workspace/__init__.py +19 -0
  3. google_workspace/client.py +253 -0
  4. google_workspace/config.py +39 -0
  5. google_workspace/errors.py +19 -0
  6. google_workspace/mcp_server.py +100 -0
  7. google_workspace/oauth.py +131 -0
  8. google_workspace/principal.py +83 -0
  9. google_workspace/py.typed +0 -0
  10. google_workspace/tools.py +64 -0
  11. google_workspace/writes.py +44 -0
  12. hoja/__init__.py +29 -0
  13. hoja/__main__.py +6 -0
  14. hoja/auth.py +42 -0
  15. hoja/cli.py +161 -0
  16. hoja/cli_state.py +55 -0
  17. hoja/client.py +268 -0
  18. hoja/commands/__init__.py +4 -0
  19. hoja/commands/auth_cmd.py +271 -0
  20. hoja/commands/comment.py +149 -0
  21. hoja/commands/cycle.py +75 -0
  22. hoja/commands/init.py +108 -0
  23. hoja/commands/issue.py +398 -0
  24. hoja/commands/label.py +163 -0
  25. hoja/commands/memory.py +240 -0
  26. hoja/commands/project.py +56 -0
  27. hoja/commands/raw.py +50 -0
  28. hoja/commands/team.py +102 -0
  29. hoja/commands/update.py +162 -0
  30. hoja/config.py +362 -0
  31. hoja/errors.py +107 -0
  32. hoja/filters.py +150 -0
  33. hoja/graphql/__init__.py +5 -0
  34. hoja/graphql/fragments.py +138 -0
  35. hoja/graphql/mutations.py +160 -0
  36. hoja/graphql/queries.py +245 -0
  37. hoja/mcp/__init__.py +27 -0
  38. hoja/mcp/content.py +151 -0
  39. hoja/mcp/prompts.py +298 -0
  40. hoja/mcp/resources.py +213 -0
  41. hoja/mcp/server.py +189 -0
  42. hoja/memory_board.py +370 -0
  43. hoja/models/__init__.py +37 -0
  44. hoja/models/base.py +64 -0
  45. hoja/models/cycle.py +32 -0
  46. hoja/models/enums.py +62 -0
  47. hoja/models/issue.py +99 -0
  48. hoja/models/label.py +17 -0
  49. hoja/models/project.py +60 -0
  50. hoja/models/team.py +31 -0
  51. hoja/models/user.py +32 -0
  52. hoja/models/workflow.py +65 -0
  53. hoja/output.py +410 -0
  54. hoja/skill_content/commands.md +222 -0
  55. hoja/skill_content/examples.md +193 -0
  56. hoja/skill_content/filters.md +101 -0
  57. hoja/skill_content/output-formats.md +92 -0
  58. hoja/skill_content/overview.md +123 -0
  59. hoja/skill_content/workflows.md +174 -0
  60. hoja_cli-0.21.4.dist-info/METADATA +279 -0
  61. hoja_cli-0.21.4.dist-info/RECORD +107 -0
  62. hoja_cli-0.21.4.dist-info/WHEEL +4 -0
  63. hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
  64. hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
  65. hoja_server/README.md +135 -0
  66. hoja_server/__init__.py +11 -0
  67. hoja_server/abac.py +116 -0
  68. hoja_server/agent_chat.py +280 -0
  69. hoja_server/agent_conversations.py +91 -0
  70. hoja_server/agent_durable.py +332 -0
  71. hoja_server/agent_prompt.py +117 -0
  72. hoja_server/agent_threads.py +96 -0
  73. hoja_server/agent_uploads.py +60 -0
  74. hoja_server/app.py +2189 -0
  75. hoja_server/cadence.py +231 -0
  76. hoja_server/cli.py +191 -0
  77. hoja_server/cloudflare_realtime.py +110 -0
  78. hoja_server/comment_policy.py +70 -0
  79. hoja_server/connector_actions.py +119 -0
  80. hoja_server/connectors_api.py +375 -0
  81. hoja_server/db.py +1816 -0
  82. hoja_server/denials.py +18 -0
  83. hoja_server/deployment.py +28 -0
  84. hoja_server/email_adapter.py +94 -0
  85. hoja_server/google_login.py +217 -0
  86. hoja_server/google_oidc.py +342 -0
  87. hoja_server/identity_transfer.py +210 -0
  88. hoja_server/mcp_oauth.py +623 -0
  89. hoja_server/mcp_tools.py +579 -0
  90. hoja_server/meeting_policy.py +179 -0
  91. hoja_server/meetings.py +1332 -0
  92. hoja_server/meetings_api.py +909 -0
  93. hoja_server/model_providers.py +205 -0
  94. hoja_server/realtime.py +690 -0
  95. hoja_server/resolvers.py +1281 -0
  96. hoja_server/schema.graphql +477 -0
  97. hoja_server/schema_sdl.py +8 -0
  98. hoja_server/secrets_box.py +39 -0
  99. hoja_server/signup_config.py +292 -0
  100. hoja_server/signup_gate.py +393 -0
  101. hoja_server/signup_policy.py +97 -0
  102. hoja_server/signup_store.py +577 -0
  103. hoja_server/store.py +2179 -0
  104. hoja_server/sync.py +249 -0
  105. hoja_server/tenant_context.py +131 -0
  106. hoja_server/turnstile.py +81 -0
  107. hoja_server/writer.py +1262 -0
@@ -0,0 +1,690 @@
1
+ """Realtime change feed shared by every WebSocket client of one server process.
2
+
3
+ Sources (the ``change_event`` table stays the source of truth for catch-up):
4
+
5
+ - PostgreSQL: the change triggers ``pg_notify('hoja_change', ...)`` at commit, so
6
+ every server process/node LISTENing on the channel wakes on any node's write. A
7
+ slow per-org catch-up poll is the safety net (and the primary path while the
8
+ LISTEN connection is down). Change triggers run deferred at commit under a
9
+ per-org advisory lock, so per-org seqs are commit-ordered and "everything after
10
+ seq N for this org" is gap-free.
11
+ - SQLite: a 100 ms global poll, kicked immediately after a local mutation.
12
+
13
+ Each org has a ring of published batches. A batch carries its events and the
14
+ issue rows they touched (fetched once, unscoped); frames are encoded once per
15
+ (visibility set, protocol) and shared by every socket in that group, so fan-out
16
+ costs one ``send`` per client and no per-client database work.
17
+
18
+ Wire protocol (``/ws``):
19
+
20
+ - v1 (default, legacy): each frame is a JSON array of ChangeEvent objects
21
+ ``{seq, entity, entityId, action, teamId}`` sorted by seq (unchanged format).
22
+ - v2 (``/ws?v=2``): each frame is an object::
23
+
24
+ {"v": 2, "prev": P, "seq": S, "events": [ChangeEvent...],
25
+ "columns": [...], "issues": [[row]...], "removed": [id...], "full": bool}
26
+
27
+ Events are coalesced per (entity, entityId), keeping the newest (its action
28
+ is the latest action). Contract: every change visible to this viewer with ``P < seq <= S`` is in
29
+ ``events``. When ``full`` is true, ``issues``/``removed`` cover every issue
30
+ event (rows use /sync's columnar format and are visibility-filtered), so a
31
+ store at seq ``s`` with ``P <= s < S`` can apply them and move to ``S`` without
32
+ a /sync/delta round trip; if ``s < P`` it must pull a delta. The first frame
33
+ after connect always arrives (possibly with no events) and carries the head
34
+ seq. ``{"v": 2, "reset": true, "seq": S}`` means "too far behind, pull".
35
+ - Visibility: events of teams the viewer cannot see are never sent; per-user
36
+ events (notifications) carry ``recipientId`` and reach only that recipient.
37
+ - Cursor: ``after=SEQ`` replays visible events after SEQ (bounded); omitting it
38
+ starts at the live head with no replay.
39
+ - Close codes: 4400 bad cursor, 4401 not (or no longer) authenticated, 4408 the
40
+ client could not keep up (reconnect and catch up).
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import asyncio
45
+ import itertools
46
+ import json
47
+ import logging
48
+ import time
49
+ from collections import deque
50
+ from contextlib import suppress
51
+ from dataclasses import dataclass, field
52
+
53
+ from starlette.websockets import WebSocketDisconnect
54
+ from hoja_server import tenant_context
55
+
56
+ from hoja_server import sync
57
+
58
+ log = logging.getLogger("hoja_server.realtime")
59
+
60
+ NOTIFY_CHANNEL = "hoja_change"
61
+ POLL_INTERVAL = 0.1 # SQLite / PostgreSQL-without-LISTEN
62
+ PG_SAFETY_POLL_INTERVAL = 2.0 # PostgreSQL while LISTEN is healthy
63
+ NOTIFY_COALESCE = 0.002 # gather a burst of notifications into one batch
64
+ RING_BATCHES = 4096
65
+ ROW_PUSH_LIMIT = 200 # issue rows pushed inline per batch; beyond, clients pull
66
+ MAX_REPLAY = 500
67
+ VISIBILITY_TTL = 30.0
68
+ REVALIDATE_SECONDS = 5.0
69
+ SEND_TIMEOUT = 10.0
70
+ FEED_IDLE_SECONDS = 600.0
71
+ # Entities that can change who sees which team.
72
+ VISIBILITY_ENTITIES = {"team", "teamMember", "user", "member"}
73
+ _dumps = json.JSONEncoder(separators=(",", ":")).encode
74
+
75
+
76
+ def coalesce(events: list[dict]) -> list[dict]:
77
+ """Keep the newest event per (entity, entityId), in seq order."""
78
+ if len(events) < 2:
79
+ return events
80
+ latest: dict[tuple, dict] = {}
81
+ for e in events:
82
+ latest[(e["entity"], e["entityId"])] = e
83
+ if len(latest) == len(events):
84
+ return events
85
+ return sorted(latest.values(), key=lambda e: e["seq"])
86
+
87
+
88
+ def _visible_events(events: list[dict], vis: frozenset | None, uid: str) -> list[dict]:
89
+ """Team-visible events; per-user events (recipientId) only for their recipient."""
90
+ return [e for e in events
91
+ if (vis is None or e["teamId"] is None or e["teamId"] in vis)
92
+ and e.get("recipientId", uid) == uid]
93
+
94
+
95
+ def issue_payload(events: list[dict], rows: dict | None, vis: frozenset | None) -> tuple[list, list]:
96
+ """(visible rows, removed ids) for the issue events, given unscoped rows by id."""
97
+ issues, removed = [], []
98
+ for iid in dict.fromkeys(e["entityId"] for e in events if e["entity"] == "issue"):
99
+ row = rows.get(iid) if rows is not None else None
100
+ if row is not None and sync.row_visible(row, vis):
101
+ issues.append(row)
102
+ else:
103
+ removed.append(iid) # deleted, archived, or moved to a team this viewer cannot see
104
+ return issues, removed
105
+
106
+
107
+ class Batch:
108
+ __slots__ = ("entities", "events", "first_seq", "frames", "idx", "issue_ids", "recipients", "rows", "seq")
109
+
110
+ def __init__(self, idx: int, events: list[dict], rows: dict | None) -> None:
111
+ self.idx = idx
112
+ self.events = events
113
+ self.first_seq = events[0]["seq"]
114
+ self.seq = events[-1]["seq"]
115
+ self.rows = rows # issue id -> unscoped row or None (gone); None when not fetched
116
+ self.entities = {e["entity"] for e in events}
117
+ self.issue_ids = {e["entityId"] for e in events if e["entity"] == "issue"}
118
+ self.recipients = {e["recipientId"] for e in events if "recipientId" in e}
119
+ self.frames: dict = {}
120
+
121
+ def body(self, vis: frozenset | None, proto: int, uid: str) -> str | None:
122
+ """Encoded frame for a visibility group (v2 bodies lack the per-socket prev).
123
+
124
+ Viewers share a frame unless the batch holds a per-user event for them.
125
+ """
126
+ who = uid if uid in self.recipients else None
127
+ key = (vis, proto, who)
128
+ if key in self.frames:
129
+ return self.frames[key]
130
+ evs = _visible_events(self.events, vis, uid if who else "")
131
+ if not evs:
132
+ out = None
133
+ elif proto == 1:
134
+ out = _dumps(evs) # legacy frames keep every event, exactly as before
135
+ else:
136
+ evs = coalesce(evs)
137
+ issues, removed = issue_payload(evs, self.rows, vis)
138
+ out = _dumps({"seq": self.seq, "events": evs, "columns": sync.ISSUE_COLUMNS,
139
+ "issues": issues, "removed": removed, "full": self.rows is not None})
140
+ self.frames[key] = out
141
+ return out
142
+
143
+
144
+ def v2_frame(prev: int, body: str) -> str:
145
+ return '{"v":2,"prev":' + str(int(prev)) + "," + body[1:]
146
+
147
+
148
+ @dataclass(eq=False)
149
+ class Conn:
150
+ uid: str
151
+ org: str
152
+ cred: tuple | None
153
+ websocket: object
154
+ closing: int | None = None
155
+ ended: bool = False
156
+ task: asyncio.Task | None = None
157
+
158
+
159
+ @dataclass(eq=False)
160
+ class Feed:
161
+ org: str
162
+ head: int # newest published seq for this org
163
+ covered_from: int # every event with seq > covered_from is in the ring
164
+ ring: deque = field(default_factory=deque)
165
+ next_idx: int = 1
166
+ waiter: asyncio.Future | None = None
167
+ conns: set = field(default_factory=set)
168
+ vis: dict = field(default_factory=dict) # uid -> (monotonic time, frozenset | None)
169
+ vis_gen: int = 0
170
+ vis_task: asyncio.Future | None = None
171
+ meta_seq: int = 0
172
+ last_active: float = field(default_factory=time.monotonic)
173
+
174
+ def wake(self) -> None:
175
+ old, self.waiter = self.waiter, asyncio.get_running_loop().create_future()
176
+ if old is not None and not old.done():
177
+ old.set_result(None)
178
+
179
+ def publish(self, events: list[dict], rows: dict | None) -> Batch:
180
+ batch = Batch(self.next_idx, events, rows)
181
+ self.next_idx += 1
182
+ self.ring.append(batch)
183
+ if len(self.ring) > RING_BATCHES:
184
+ self.covered_from = self.ring.popleft().seq
185
+ self.head = max(self.head, batch.seq)
186
+ self.last_active = time.monotonic()
187
+ if batch.entities & VISIBILITY_ENTITIES:
188
+ self.vis.clear()
189
+ self.vis_gen += 1
190
+ if batch.entities & sync.META_ENTITIES:
191
+ self.meta_seq = batch.seq
192
+ self.wake()
193
+ return batch
194
+
195
+ def batches_after(self, idx: int) -> list[Batch] | None:
196
+ """Batches newer than idx, or None when some were evicted from the ring."""
197
+ if not self.ring:
198
+ return [] if idx >= self.next_idx - 1 else None
199
+ start = idx + 1 - self.ring[0].idx
200
+ if start < 0:
201
+ return None
202
+ return list(itertools.islice(self.ring, start, None))
203
+
204
+
205
+ class RealtimeHub:
206
+ def __init__(self, store, *, poll_interval: float = POLL_INTERVAL) -> None:
207
+ self.store = store
208
+ self.dialect = store.engine.dialect.name
209
+ self.poll_interval = poll_interval
210
+ self.feeds: dict[str, Feed] = {}
211
+ self.conns: set[Conn] = set()
212
+ self.cursor = 0 # SQLite global poll cursor
213
+ self.listening = False
214
+ self.stats = {"batches": 0, "events": 0, "notifies": 0, "polls": 0, "frames": 0, "row_fetches": 0,
215
+ "snapshot_derived": 0, "snapshot_full": 0}
216
+ self._ingest_lock = asyncio.Lock()
217
+ self._kick = asyncio.Event()
218
+ self._tasks: list[asyncio.Task] = []
219
+ self._pending: list[dict] = []
220
+ self._flush_task: asyncio.Task | None = None
221
+ self._last_poll_error = 0.0
222
+ self._loop: asyncio.AbstractEventLoop | None = None
223
+ self.validate = None # set by the app: creds -> {cred: (uid, org)}
224
+
225
+ # ------------------------------------------------------------------ lifecycle
226
+ async def ensure_started(self) -> None:
227
+ """Start on the running loop (restarting from scratch if the loop changed, as in tests)."""
228
+ loop = asyncio.get_running_loop()
229
+ if self._loop is loop:
230
+ return
231
+ self._loop = loop
232
+ self.feeds, self.conns, self._tasks, self._pending = {}, set(), [], []
233
+ self._ingest_lock, self._kick, self._flush_task = asyncio.Lock(), asyncio.Event(), None
234
+ await self.start()
235
+
236
+ async def start(self) -> None:
237
+ try:
238
+ with tenant_context.system(): # the global head, before any tenant
239
+ self.cursor = await asyncio.to_thread(self.store.max_change_seq)
240
+ except Exception:
241
+ log.warning("realtime feed starting without change_event (schema not migrated?)")
242
+ # The hub serves every organization: its loops are system context, and
243
+ # frames are still filtered per viewer before they are sent.
244
+ sys_task = lambda coro: asyncio.create_task(tenant_context.as_system(coro))
245
+ self._tasks.append(sys_task(self._poll_loop()))
246
+ self._tasks.append(sys_task(self._revalidate_loop()))
247
+ self._tasks.append(sys_task(self._evict_loop()))
248
+ if self.dialect == "postgresql":
249
+ self._tasks.append(sys_task(self._listen_loop()))
250
+
251
+ async def stop(self) -> None:
252
+ for task in self._tasks:
253
+ task.cancel()
254
+ for task in self._tasks:
255
+ with suppress(asyncio.CancelledError, Exception):
256
+ await task
257
+ self._tasks.clear()
258
+ self._loop = None
259
+
260
+ def kick(self) -> None:
261
+ """A local write committed: poll now instead of at the next tick."""
262
+ self._kick.set()
263
+
264
+ # ------------------------------------------------------------------ feeds
265
+ async def feed(self, org: str) -> Feed:
266
+ await self.ensure_started()
267
+ feed = self.feeds.get(org)
268
+ if feed is not None:
269
+ return feed
270
+ latest = await asyncio.to_thread(self.store.latest_change_seq, org)
271
+ feed = self.feeds.get(org)
272
+ if feed is None:
273
+ feed = self.feeds[org] = Feed(org=org, head=latest, covered_from=latest)
274
+ feed.wake()
275
+ return feed
276
+
277
+ async def ingest(self, rows: list[dict]) -> None:
278
+ """Publish raw events (with organization_id) as one batch per org, in seq order."""
279
+ if not rows:
280
+ return
281
+ async with self._ingest_lock:
282
+ by_org: dict[str, list[dict]] = {}
283
+ for r in rows:
284
+ feed = self.feeds.get(r["organization_id"])
285
+ if feed is not None and r["seq"] <= feed.head:
286
+ continue # already published (per-org seqs are commit-ordered)
287
+ ev = {"seq": r["seq"], "entity": r["entity"], "entityId": r["entityId"],
288
+ "action": r["action"], "teamId": r["teamId"]}
289
+ if r.get("recipientId") is not None:
290
+ ev["recipientId"] = r["recipientId"]
291
+ by_org.setdefault(r["organization_id"], []).append(ev)
292
+ if not by_org:
293
+ return
294
+ for evs in by_org.values():
295
+ evs.sort(key=lambda e: e["seq"])
296
+ wanted = {}
297
+ for org, evs in by_org.items():
298
+ feed = self.feeds.get(org)
299
+ if feed is None or not feed.conns:
300
+ continue # nobody watching: deltas read rows from the database instead
301
+ ids = list(dict.fromkeys(e["entityId"] for e in evs if e["entity"] == "issue"))
302
+ if 0 < len(ids) <= ROW_PUSH_LIMIT:
303
+ wanted[org] = ids
304
+ fetched: dict[str, dict] = {}
305
+ if wanted:
306
+ try:
307
+ fetched = await asyncio.to_thread(self._fetch_rows, wanted)
308
+ self.stats["row_fetches"] += 1
309
+ except Exception:
310
+ log.exception("realtime row fetch failed; frames fall back to invalidation")
311
+ for org, evs in by_org.items():
312
+ feed = self.feeds.get(org)
313
+ if feed is None:
314
+ # First event seen for this org: everything before it is in the database.
315
+ feed = self.feeds[org] = Feed(org=org, head=evs[0]["seq"] - 1,
316
+ covered_from=evs[0]["seq"] - 1)
317
+ feed.wake()
318
+ has_issue = any(e["entity"] == "issue" for e in evs)
319
+ feed.publish(evs, fetched.get(org) if has_issue else {})
320
+ self.stats["batches"] += 1
321
+ self.stats["events"] += len(evs)
322
+
323
+ def _fetch_rows(self, wanted: dict[str, list[str]]) -> dict[str, dict]:
324
+ out = {}
325
+ for org, ids in wanted.items():
326
+ rows = {r[0]: r for r in sync.issues_by_id(self.store, org, None, ids)}
327
+ out[org] = {iid: rows.get(iid) for iid in ids}
328
+ return out
329
+
330
+ # ------------------------------------------------------------------ sources
331
+ async def _poll_loop(self) -> None:
332
+ while True:
333
+ self._kick.clear()
334
+ try:
335
+ if self.dialect == "postgresql":
336
+ await self._poll_orgs()
337
+ else:
338
+ await self._poll_global()
339
+ except asyncio.CancelledError:
340
+ raise
341
+ except Exception:
342
+ now = time.monotonic()
343
+ if now - self._last_poll_error > 60:
344
+ self._last_poll_error = now
345
+ log.exception("realtime poll failed")
346
+ interval = PG_SAFETY_POLL_INTERVAL if self.listening else self.poll_interval
347
+ with suppress(asyncio.TimeoutError):
348
+ await asyncio.wait_for(self._kick.wait(), timeout=interval)
349
+
350
+ async def _poll_global(self) -> None:
351
+ while True:
352
+ rows = await asyncio.to_thread(self.store.change_events_after_global, self.cursor, 5000)
353
+ self.stats["polls"] += 1
354
+ if not rows:
355
+ return
356
+ self.cursor = max(self.cursor, rows[-1]["seq"])
357
+ await self.ingest(rows)
358
+ if len(rows) < 5000:
359
+ return
360
+
361
+ async def _poll_orgs(self) -> None:
362
+ heads = {org: feed.head for org, feed in self.feeds.items()}
363
+ if not heads:
364
+ return
365
+ rows = await asyncio.to_thread(self.store.change_events_after_orgs, heads)
366
+ self.stats["polls"] += 1
367
+ await self.ingest(rows)
368
+
369
+ def _pg_dsn(self) -> str:
370
+ url = self.store.engine.url.set(drivername="postgresql")
371
+ return url.render_as_string(hide_password=False)
372
+
373
+ async def _listen_loop(self) -> None:
374
+ import psycopg
375
+
376
+ backoff = 0.5
377
+ while True:
378
+ try:
379
+ async with await psycopg.AsyncConnection.connect(self._pg_dsn(), autocommit=True) as conn:
380
+ await conn.execute(f"LISTEN {NOTIFY_CHANNEL}")
381
+ self.listening = True
382
+ backoff = 0.5
383
+ # Catch up anything committed while we were not listening.
384
+ self._kick.set()
385
+ async for note in conn.notifies():
386
+ self._on_notify(note.payload)
387
+ except asyncio.CancelledError:
388
+ self.listening = False
389
+ raise
390
+ except Exception:
391
+ log.warning("realtime LISTEN connection lost; polling until it recovers", exc_info=True)
392
+ self.listening = False
393
+ self._kick.set()
394
+ await asyncio.sleep(backoff)
395
+ backoff = min(10.0, backoff * 2)
396
+
397
+ def _on_notify(self, payload: str) -> None:
398
+ try:
399
+ seq, org, entity, entity_id, action, team_id, *rest = json.loads(payload)
400
+ except (ValueError, TypeError):
401
+ return
402
+ self.stats["notifies"] += 1
403
+ self._pending.append({"seq": int(seq), "organization_id": org, "entity": entity,
404
+ "entityId": entity_id, "action": action, "teamId": team_id,
405
+ "recipientId": rest[0] if rest else None})
406
+ if self._flush_task is None or self._flush_task.done():
407
+ self._flush_task = asyncio.create_task(tenant_context.as_system(self._flush_notifies()))
408
+
409
+ async def _flush_notifies(self) -> None:
410
+ await asyncio.sleep(NOTIFY_COALESCE)
411
+ while self._pending:
412
+ rows, self._pending = self._pending, []
413
+ try:
414
+ await self.ingest(rows)
415
+ except Exception:
416
+ log.exception("realtime ingest failed; the safety poll will retry")
417
+ self._kick.set()
418
+
419
+ # ------------------------------------------------------------------ visibility
420
+ async def visibility(self, feed: Feed, uid: str) -> frozenset | None:
421
+ """Viewer's visible teams, refreshed for every subscriber of the org in one read."""
422
+ while True:
423
+ now = time.monotonic()
424
+ entry = feed.vis.get(uid)
425
+ if entry is not None and now - entry[0] < VISIBILITY_TTL:
426
+ return entry[1]
427
+ if feed.vis_task is not None:
428
+ with suppress(Exception):
429
+ await asyncio.shield(feed.vis_task)
430
+ continue
431
+ task = feed.vis_task = asyncio.get_running_loop().create_future()
432
+ gen = feed.vis_gen
433
+ uids = {uid} | {c.uid for c in feed.conns
434
+ if (e := feed.vis.get(c.uid)) is None or now - e[0] >= VISIBILITY_TTL / 2}
435
+ try:
436
+ result = await asyncio.to_thread(self.store.visibility_map, feed.org, uids)
437
+ except BaseException as exc:
438
+ feed.vis_task = None
439
+ task.set_exception(exc)
440
+ task.exception()
441
+ raise
442
+ feed.vis_task = None
443
+ task.set_result(None)
444
+ if gen != feed.vis_gen:
445
+ continue # membership changed while we read; read again
446
+ for u, v in result.items():
447
+ feed.vis[u] = (now, v)
448
+ return result[uid]
449
+
450
+ # ------------------------------------------------------------------ revalidation
451
+ async def _revalidate_loop(self) -> None:
452
+ while True:
453
+ await asyncio.sleep(REVALIDATE_SECONDS)
454
+ conns = [c for c in self.conns if c.cred is not None and c.closing is None]
455
+ if not conns or self.validate is None:
456
+ continue
457
+ try:
458
+ valid = await asyncio.to_thread(self.validate, {c.cred for c in conns})
459
+ except Exception:
460
+ log.exception("realtime credential revalidation failed")
461
+ continue
462
+ for c in conns:
463
+ if valid.get(c.cred) != (c.uid, c.org):
464
+ self.close(c, 4401)
465
+
466
+ def close(self, conn: Conn, code: int) -> None:
467
+ conn.closing = code
468
+ if conn.task is not None and not conn.task.done():
469
+ conn.task.cancel()
470
+
471
+ async def _evict_loop(self) -> None:
472
+ while True:
473
+ await asyncio.sleep(60)
474
+ now = time.monotonic()
475
+ for org, feed in list(self.feeds.items()):
476
+ if not feed.conns and now - feed.last_active > FEED_IDLE_SECONDS:
477
+ del self.feeds[org]
478
+
479
+ # ------------------------------------------------------------------ snapshot derivation
480
+ async def wait_head(self, org: str, seq: int, timeout: float = 0.25) -> bool:
481
+ """Wait (briefly) until this org's feed has published everything up to seq."""
482
+ feed = self.feeds.get(org)
483
+ if feed is None:
484
+ return False
485
+ deadline = time.monotonic() + timeout
486
+ while feed.head < seq:
487
+ left = deadline - time.monotonic()
488
+ if left <= 0 or self.feeds.get(org) is not feed:
489
+ return False
490
+ self.kick()
491
+ with suppress(asyncio.TimeoutError):
492
+ await asyncio.wait_for(asyncio.shield(feed.waiter), timeout=left)
493
+ return True
494
+
495
+ def issue_changes(self, org: str, after: int, upto: int) -> dict | None:
496
+ """Unscoped rows (None = gone) of issues changed in (after, upto], or None if unknown."""
497
+ feed = self.feeds.get(org)
498
+ if feed is None or after < feed.covered_from or feed.head < upto:
499
+ return None
500
+ changes: dict = {}
501
+ for batch in feed.ring:
502
+ if batch.seq <= after:
503
+ continue
504
+ if batch.first_seq > upto:
505
+ break
506
+ if "workflowState" in batch.entities:
507
+ return None # a state's type change can move many issues in or out of the window
508
+ ids = {e["entityId"] for e in batch.events
509
+ if e["entity"] == "issue" and after < e["seq"] <= upto}
510
+ if ids and batch.rows is None:
511
+ return None
512
+ for i in ids:
513
+ changes[i] = batch.rows.get(i)
514
+ return changes
515
+
516
+ # ------------------------------------------------------------------ delta from memory
517
+ async def delta(self, org: str, uid: str, after: int) -> dict | None:
518
+ """/sync/delta served from the ring, or None when the database must answer."""
519
+ await self.ensure_started()
520
+ feed = self.feeds.get(org)
521
+ if feed is None or after < feed.covered_from or after > feed.head:
522
+ return None
523
+ # Read-your-writes: a client pulling right after its own mutation must see
524
+ # it even if the broadcast has not ingested it yet (one indexed MAX query).
525
+ if await asyncio.to_thread(self.store.latest_change_seq, org) > feed.head:
526
+ return None
527
+ events: list[dict] = []
528
+ batches: list[Batch] = []
529
+ for batch in reversed(feed.ring):
530
+ if batch.seq <= after:
531
+ break
532
+ batches.append(batch)
533
+ batches.reverse()
534
+ for batch in batches:
535
+ events.extend(e for e in batch.events if e["seq"] > after)
536
+ head = feed.head
537
+ if len(events) > sync.MAX_DELTA_EVENTS:
538
+ return {"v": sync.SYNC_VERSION, "reload": True, "seq": head}
539
+ vis = await self.visibility(feed, uid)
540
+ events = _visible_events(events, vis, uid)
541
+ entities = {e["entity"] for e in events}
542
+ if entities & sync.RELOAD_ENTITIES:
543
+ return {"v": sync.SYNC_VERSION, "reload": True, "seq": head}
544
+ rows: dict = {}
545
+ missing: list[str] = []
546
+ touched = list(dict.fromkeys(e["entityId"] for e in events if e["entity"] == "issue"))
547
+ if touched:
548
+ want = set(touched)
549
+ for batch in reversed(batches): # newest row wins
550
+ if not want:
551
+ break
552
+ for i in batch.issue_ids & want:
553
+ want.discard(i)
554
+ if batch.rows is None:
555
+ missing.append(i)
556
+ else:
557
+ rows[i] = batch.rows.get(i)
558
+ missing.extend(want)
559
+ issues, removed = issue_payload([e for e in events if e["entity"] == "issue"
560
+ and e["entityId"] not in missing], rows, vis)
561
+ if missing:
562
+ fresh = await asyncio.to_thread(sync.issues_by_id, self.store, org, uid, missing)
563
+ present = {r[0] for r in fresh}
564
+ issues.extend(fresh)
565
+ removed.extend(i for i in missing if i not in present)
566
+ return {
567
+ "v": sync.SYNC_VERSION, "reload": False, "seq": head,
568
+ "columns": sync.ISSUE_COLUMNS, "issues": issues, "removed": removed,
569
+ "meta": bool(entities & sync.META_ENTITIES),
570
+ "other": sorted(entities - sync.META_ENTITIES - {"issue"}),
571
+ }
572
+
573
+ # ------------------------------------------------------------------ sockets
574
+ async def serve(self, websocket, uid: str, org: str, cred: tuple | None,
575
+ cursor: int | None, proto: int) -> None:
576
+ feed = await self.feed(org)
577
+ conn = Conn(uid=uid, org=org, cred=cred, websocket=websocket)
578
+ conn.task = asyncio.current_task()
579
+ feed.conns.add(conn)
580
+ self.conns.add(conn)
581
+ reader = asyncio.create_task(self._read_until_closed(websocket, conn))
582
+ try:
583
+ await self._pump(feed, conn, cursor, proto)
584
+ except asyncio.CancelledError:
585
+ if conn.closing is None and not conn.ended:
586
+ raise
587
+ except asyncio.TimeoutError:
588
+ conn.closing = 4408 # could not keep up; it reconnects and catches up from its cursor
589
+ except (RuntimeError, OSError, ConnectionError, WebSocketDisconnect):
590
+ pass # client went away mid-send
591
+ finally:
592
+ reader.cancel()
593
+ feed.conns.discard(conn)
594
+ self.conns.discard(conn)
595
+ if conn.closing is not None and not conn.ended:
596
+ with suppress(Exception):
597
+ await asyncio.wait_for(websocket.close(code=conn.closing), timeout=1.0)
598
+
599
+ async def _read_until_closed(self, websocket, conn: Conn) -> None:
600
+ with suppress(asyncio.CancelledError):
601
+ try:
602
+ while True:
603
+ message = await websocket.receive()
604
+ if message["type"] == "websocket.disconnect":
605
+ break
606
+ except (WebSocketDisconnect, RuntimeError):
607
+ pass
608
+ conn.ended = True
609
+ if conn.task is not None and not conn.task.done():
610
+ conn.task.cancel()
611
+
612
+ async def _send(self, websocket, text: str) -> None:
613
+ await asyncio.wait_for(websocket.send_text(text), timeout=SEND_TIMEOUT)
614
+ self.stats["frames"] += 1
615
+
616
+ async def _backlog(self, feed: Feed, conn: Conn, cursor: int, proto: int) -> tuple[str | None, int]:
617
+ """Replay frame for events after cursor, and the max seq it covers."""
618
+ latest = feed.head
619
+ start = cursor
620
+ if proto == 1 and latest - cursor > MAX_REPLAY:
621
+ start = max(0, latest - MAX_REPLAY) # legacy clients refetch on any event anyway
622
+ events = await asyncio.to_thread(self.store.change_events_after, conn.org, start,
623
+ MAX_REPLAY + 1, conn.uid)
624
+ if len(events) > MAX_REPLAY:
625
+ if proto == 1:
626
+ events = events[-MAX_REPLAY:]
627
+ else:
628
+ head = max(feed.head, events[-1]["seq"])
629
+ return _dumps({"v": 2, "reset": True, "seq": head}), head
630
+ top = max([feed.head] + [e["seq"] for e in events])
631
+ if proto == 1:
632
+ return (_dumps(events) if events else None), top
633
+ evs = coalesce(events)
634
+ issues, removed, full = [], [], True
635
+ ids = list(dict.fromkeys(e["entityId"] for e in evs if e["entity"] == "issue"))
636
+ if len(ids) > ROW_PUSH_LIMIT:
637
+ full = False
638
+ elif ids:
639
+ fresh = await asyncio.to_thread(sync.issues_by_id, self.store, conn.org, conn.uid, ids)
640
+ present = {r[0] for r in fresh}
641
+ issues, removed = fresh, [i for i in ids if i not in present]
642
+ body = _dumps({"seq": top, "events": evs, "columns": sync.ISSUE_COLUMNS,
643
+ "issues": issues, "removed": removed, "full": full})
644
+ return v2_frame(cursor, body), top
645
+
646
+ async def _pump(self, feed: Feed, conn: Conn, cursor: int | None, proto: int) -> None:
647
+ ws = conn.websocket
648
+ pos = feed.next_idx - 1
649
+ if cursor is None:
650
+ # Start at the database head, not the feed's: events committed but not
651
+ # yet broadcast are history this client's snapshot already covers.
652
+ sent = max(feed.head, await asyncio.to_thread(self.store.latest_change_seq, conn.org))
653
+ if proto == 2:
654
+ await self._send(ws, _dumps({"v": 2, "prev": sent, "seq": sent, "events": [],
655
+ "columns": sync.ISSUE_COLUMNS, "issues": [],
656
+ "removed": [], "full": True}))
657
+ else:
658
+ frame, sent = await self._backlog(feed, conn, cursor, proto)
659
+ if frame is not None:
660
+ await self._send(ws, frame)
661
+ prev = sent
662
+ while True:
663
+ waiter = feed.waiter
664
+ batches = feed.batches_after(pos)
665
+ if batches is None:
666
+ # Fell off the ring (very slow client): catch up from the database.
667
+ pos = feed.next_idx - 1
668
+ frame, top = await self._backlog(feed, conn, prev, proto)
669
+ if frame is not None:
670
+ await self._send(ws, frame)
671
+ prev = sent = max(sent, top)
672
+ continue
673
+ if batches:
674
+ vis = await self.visibility(feed, conn.uid)
675
+ for batch in batches:
676
+ pos = batch.idx
677
+ if batch.seq <= sent:
678
+ continue
679
+ if batch.first_seq <= sent:
680
+ # Overlaps the replayed backlog: build an unshared frame without the dupes.
681
+ part = Batch(batch.idx, [e for e in batch.events if e["seq"] > sent], batch.rows)
682
+ body = part.body(vis, proto, conn.uid)
683
+ else:
684
+ body = batch.body(vis, proto, conn.uid)
685
+ if body is not None:
686
+ await self._send(ws, body if proto == 1 else v2_frame(prev, body))
687
+ prev = batch.seq
688
+ sent = max(sent, batch.seq)
689
+ continue
690
+ await asyncio.shield(waiter)