PageSourceSearch

https://smrtbld.ai/js/realtime/TaskGroupSync.js

js smrtbld.ai collected 2026-10-05 15:11:14 UTC 32,713 bytes, 788 lines download raw bytes

1/**
2 * TaskGroupSync — live task-page refresh over the notification hub's TaskPage groups.
3 *
4 * The modern home of the legacy refreshTaskComments flow: when someone comments on or
5 * updates a task you have open, the backend broadcasts to TaskPage_{company}_{threadId}
6 * and your comment list refreshes without a reload (legacy smrtbldSignalR.js:1806-1809 →
7 * 'app:refreshComments').
8 *
9 * Leader-election aware: only the leader tab holds the SignalR connection, so the leader
10 * joins the union of every tab's interests (relayed via CrossTabSync) and re-broadcasts
11 * group events to the other tabs. Interests from other tabs carry a keepalive timestamp so
12 * a dead tab's groups get pruned.
13 *
14 * Usage (page module init/destroy):
15 *   import { subscribeTaskUpdates } from '../realtime/TaskGroupSync.js';
16 *   const sub = subscribeTaskUpdates(`asgn-${taskId}`, () => refreshComments());
17 *   // in destroy/back-to-list:
18 *   sub.dispose();
19 */
20
21import { pubsub } from '../utils/pubsub.js';
22import CrossTabSync from '../notifications/CrossTabSync.js';
23import { getNotificationHubClient, isConnected } from './NotificationHubClient.js';
24import { offlineStore } from '../offline/offline-store.js';
25
26// threadId → Set<callback> (this tab's interests)
27const localSubscriptions = new Map();
28
29// threadId → Map<tabId, lastSeenMs> (other tabs' interests — leader only acts on these)
30const remoteInterests = new Map();
31
32// "{partitionKey}|{projectId}" → Set<callback> (this tab's Markups register interests)
33const localMarkupSubscriptions = new Map();
34const localMarkupRegisterSubscriptions = new Map();
35
36// interestKey → { partitionKey, projectId, tabs: Map<tabId,lastSeenMs> }
37const remoteMarkupInterests = new Map();
38
39// interestKey -> latest awaited join owner. A monotonic token prevents an old
40// completion from publishing into a dispose/remount successor with the same key.
41const markupJoinAttempts = new Map();
42let markupJoinAttemptSequence = 0;
43
44// "{partitionKey}|{projectId}" → Map<subscriptionId, local Activity subscriber>
45const localActivitySubscriptions = new Map();
46// interestKey → { partitionKey, projectId, subscriptions: Map<subscriptionId,{tabId,lastSeen}> }
47const remoteActivityInterests = new Map();
48const activityGroupStates = new Map();
49const activityJoinAttempts = new Map();
50const activityTransitions = new Map();
51let activitySubscriptionSequence = 0;
52let activityJoinAttemptSequence = 0;
53let activityConnectionGeneration = 0;
54
55const KEEPALIVE_MS = 60_000;
56const REMOTE_TTL_MS = KEEPALIVE_MS * 2.5;
57
58let initialized = false;
59let keepaliveTimer = null;
60let pubsubSubscriptions = [];
61
62function ensureInitialized() {
63    if (initialized) return;
64    initialized = true;
65
66    pubsubSubscriptions = [
67        // Group events arrive on the leader's connection — dispatch locally + relay.
68        pubsub.subscribe('signalr:task:refreshComments', (data) => dispatchAndRelay(data)),
69        pubsub.subscribe('signalr:task:updated', (data) => dispatchAndRelay(data)),
70        pubsub.subscribe('signalr:task:refreshList', (data) => dispatchAndRelay(data)),
71        pubsub.subscribe('signalr:markup:processingChanged', (data) =>
71 dispatchMarkupAndRelay(data)),
72        pubsub.subscribe('signalr:markup:registerChanged', (data) => dispatchMarkupRegisterAndRelay(data)),
73        pubsub.subscribe('signalr:projectActivity', (data) => dispatchActivityAndRelay(data)),
74
75        // Relayed group events from the leader tab.
76        pubsub.subscribe('crosstab:taskgroup:event', (data) => dispatchLocal(data)),
77        pubsub.subscribe('crosstab:markup-processing-event', (payload) => dispatchMarkupLocal(payload)),
78        pubsub.subscribe('crosstab:markup-register-event', (payload) => dispatchMarkupRegisterLocal(payload)),
79        pubsub.subscribe('crosstab:project-activity-event', (payload) => dispatchActivityLocal(payload)),
80        pubsub.subscribe('crosstab:project-activity-ready', (payload) => receiveActivityReady(payload)),
81
82        pubsub.subscribe('crosstab:project-activity-interest', (payload) => {
83            const { action, partitionKey, projectId, tabId, subscriptionId } = payload || {};
84            const numericProjectId = positiveSafeInteger(projectId);
85            if (!partitionKey || !numericProjectId || !tabId || !subscriptionId) return;
86            const key = activityInterestKey(partitionKey, numericProjectId);
87            if (action === 'leave') {
88                const interest = remoteActivityInterests.get(key);
89                if (!interest) return;
90                const remote = interest.subscriptions.get(subscriptionId);
91                if (!remote || remote.tabId !== tabId) return;
92                interest.subscriptions.delete(subscriptionId);
93                if (interest.subscriptions.size === 0) {
94                    remoteActivityInterests.delete(key);
95                    leaveActivityIfUnwanted(interest);
96                }
97                return;
98            }
99            if (partitionKey !== currentPartitionKey()) return;
100            let interest = remoteActivityInterests.get(key);
101            if (!interest) {
102                interest = { partitionKey, projectId: numericProjectId, subscriptions: new Map() };
103                remoteActivityInterests.set(key, interest);
104            }
105            const existing = interest.subscriptions.get(subscriptionId);
106            if (existing && existing.tabId !== tabId) return;
107            interest.subscriptions.set(subscriptionId, { tabId, lastSeen: Date.now() });
108            // An existing interest is also the server-side TTL renewal. The fast path
109            // below renews authorization/lease without re-firing onReady.
110            void joinActivityIfLeader(interest);
111        }),
112
113        // Markup register interests are partition-bound. A mixed-login tab can neither
114        // register through another partition's leader nor consume its relayed payload.
115        pubsub.subscribe('crosstab:markup-processing-interest', (payload) => {
116            const { action, partitionKey, projectId, tabId } = payload || {};
117            if (!partitionKey || !projectId || !tabId) return;
118            const key = markupInterestKey(partitionKey, projectId);
119            if (action === 'leave') {
120                const interest = remoteMarkupInterests.get(key);
121                if (!interest) return;
122                interest.tabs.delete(tabId);
123                if (interest.tabs.size === 0) {
124                    remoteMarkupInterests.delete(key);
125                    leaveMarkupIfUnwanted(interest);
126                }
127                return;
128            }
129            if (partitionKey !== currentPartitionKey()) return;
130            let interest = remoteMarkupInterests.get(key);
131            if (!interest) {
132                interest = { partitionKey, projectId: Number(projectId), tabs: new Map() };
133                remoteMarkupInterests.set(key, interest);
134            }
135            interest.tabs.set(tabId, Date.now());
136            // Every follower keepalive refreshes the leader's server-side TTL. Redis
137            // registration is idempotent for the one leader connection.
138            joinMarkupIfLeader(interest);
139        }),
140
141        // Other tabs' interest management (leader maintains the union).
142        pubsub.subscribe('crosstab:taskgroup:join', ({ threadId, tabId }) => {
143            if (!threadId) return;
144            let tabs = remoteInterests.get(threadId);
145            if (!tabs) { tabs = new Map(); remoteInterests.set(threadId, tabs); }
146            const isNew = !tabs.has(tabId);
147            tabs.set(tabId, Date.now());
148            if (isNew) joinIfLeader(threadId);
149        }),
150        pubsub.subscribe('crosstab:taskgroup:leave', ({ threadId, tabId }) => {
151            const tabs = remoteInterests.get(threadId);
152            if (!tabs) return;
153            tabs.delete(tabId);
154            if (tabs.size === 0) {
155                remoteInterests.delete(threadId);
156                leaveIfUnwanted(threadId);
157            }
158        }),
159
160        // SignalR groups are connection-scoped — re-join everything after a reconnect.
161        pubsub.subscribe('signalr:notification:reconnected', () => {
162            invalidateActivityConnection();
163            rejoinAll();
164        }),
165        pubsub.subscribe('signalr:notification:connected', () => {
166            invalidateActivityConnection();
167            rejoinAll();
168        }),
169        pubsub.subscribe('signalr:notification:disconnected', () => invalidateActivityConnection()),
170
171        // New leader = new connection holder. Every tab re-broadcasts its interests so the
172        // new leader rebuilds the union; the new leader joins its own + incoming ones.
173        pubsub.subscribe('crosstab:leaderElected', () => {
174            invalidateActivityConnection();
175            broadcastInterests();
176            rejoinAll();
177        })
178    ];
179
180    // Keepalive: re-announce interests so the leader can prune dead tabs' groups.
181    keepaliveTimer = setInterval(() => {
182        broadcastInterests();
183        pruneRemoteInterests();
184    }, KEEPALIVE_MS);
185}
186
187/**
188 * Subscribe to live updates for a task thread.
189 * @param {string} threadId - "asgn-{taskId}" or "calagsn-{eventId}"
190 * @param {Function} callback - receives the event payload ({ threadId, comments? , ... })
191 * @returns {{ dispose: Function }}
192 */
193export function subscribeTaskUpdates(threadId, callback) {
194    if (!threadId || typeof callback !== 'function') {
195        return { dispose() {} };
196    }
197
198    ensureInitialized();
199
200    let callbacks = localSubscriptions.get(threadId);
201    if (!callbacks) {
202        callbacks = new Set();
203        localSubscriptions.set(threadId, callbacks);
204        joinIfLeader(threadId);
205        CrossTabSync.broadcast('taskgroup:join', { threadId, tabId: CrossTabSync.getTabId() });
206    }
207    callbacks.add(callback);
208
209    let disposed = false;
210    return {
211        dispose() {
212            if (disposed) return;
213            disposed = true;
214
215            const cbs = localSubscriptions.get(threadId);
216            if (!cbs) return;
217            cbs.delete(callback);
218            if (cbs.size === 0) {
219                localSubscriptions.delete(threadId);
220                CrossTabSync.broadcast('taskgroup:leave', { threadId, tabId: CrossTabSync.getTabId() });
221                leaveIfUnwanted(threadId);
222            }
223        }
224    };
225}
226
227/** Initialize leader-side relay listeners even when this tab has no local detail/register mount. */
228export function initTaskGroupSync() {
229    ensureInitialized();
230}
231
232/**
233 * Subscribe a mounted Project Activity feed through the existing notification leader.
234 * Partition identity is always derived from the active authenticated offline partition.
235 */
236export function subscribeProjectActivityUpdates(projectId, callback, options = {}) {
237    const numericProjectId = positiveSafeInteger(projectId);
238    const partitionKey = currentPartitionKey();
239    if (!numericProjectId || !partitionKey || typeof callback !== 'function' || options.signal?.aborted) {
240        return { subscriptionId: null, dispose() {} };
241    }
242    ensureInitialized();
243
244    const key = activityInterestKey(partitionKey, numericProjectId);
245    const subscriptionId = `${CrossTabSync.getTabId()}:${++activitySubscriptionSequence}`;
246    const record = {
247        subscriptionId,
248        partitionKey,
249        projectId: numericProjectId,
250        callback,
251        onReady: typeof options.onReady === 'function' ? options.onReady : null,
252        signal: options.signal || null,
253        lastReadyToken: null,
254    };
255    let subscriptions = localActivitySubscriptions.get(key);
256    if (!subscriptions) {
257        subscriptions = new Map();
258        localActivitySubscriptions.set(key, subscriptions);
259    }
260    subscriptions.set(subscriptionId, record);
261
262    // Local callback ownership exists before the asynchronous group admission begins.
263    broadcastActivityInterest('join', record);
264    void joinActivityIfLeader(record);
265
266    let disposed = false;
267    const dispose = () => {
268        if (disposed) return;
269        disposed = true;
270        record.signal?.removeEventListener('abort', dispose);
271        const current = localActivitySubscriptions.get(key);
272        if (!current?.delete(subscriptionId)) return;
273        broadcastActivityInterest('leave', record);
274        if (current.size === 0) {
275            localActivitySubscriptions.delete(key);
276            leaveActivityIfUnwanted(record);
277        }
278    };
279    record.signal?.addEventListener('abort', dispose, { once: true });
280    return { subscriptionId, dispose };
281}
282
283/**
284 * Subscribe a mounted Markups register to exact active-partition/project processing pushes.
285 * Follower tabs relay interest to the elected leader; no follower opens a hub connection.
286 */
287export function subscribeMarkupProcessingUpdates(projectId, partitionKey, callback) {
288    const numericProjectId = Number(projectId);
289    if (!numericProjectId || !partitionKey || typeof callback !== 'function') return { dispose() {} };
290    ensureInitialized();
291
292    const key = markupInterestKey(partitionKey, numericProjectId);
293    let callbacks = localMarkupSubscriptions.get(key);
294    if (!callbacks) {
295        const alreadyInterested = localMarkupRegisterSubscriptions.has(key);
296        callbacks = new Set();
297        localMarkupSubscriptions.set(key, callbacks);
298        const interest = { partitionKey, projectId: numericProjectId };
299        joinMarkupIfLeader(interest);
300        if (!alreadyInterested) broadcastMarkupInterest('join', interest);
301    }
302    callbacks.add(callback);
303
304    let disposed = false;
305    return {
306        dispose() {
307            if (disposed) return;
308            disposed = true;
309            const current = localMarkupSubscriptions.get(key);
310            if (!current) return;
311            current.delete(callback);
312            if (current.size === 0) {
313                localMarkupSubscriptions.delete(key);
314                const interest = { partitionKey, projectId: numericProjectId };
315                if (!localMarkupRegisterSubscriptions.has(key)) broadcastMarkupInterest('leave', interest);
316                leaveMarkupIfUnwanted(interest);
317            }
318        }
319    };
320}
321
322/** Subscribe to committed Markups-register mutations over the same authorized project group. */
323export function subscribeMarkupRegisterUpdates(projectId, partitionKey, callback) {
324    const numericProjectId = Number(projectId);
325    if (!numericProjectId || !partitionKey || typeof callback !== 'function') return { dispose() {} };
326    ensureInitialized();
327    const key = markupInterestKey(partitionKey, numericProjectId);
328    let callbacks = localMarkupRegisterSubscriptions.get(key);
329    if (!callbacks) {
330        const alreadyInterested = localMarkupSubscriptions.has(key);
331        callbacks = new Set();
332        localMarkupRegisterSubscriptions.set(key, callbacks);
333        const interest = { partitionKey, projectId: numericProjectId };
334        joinMarkupIfLeader(interest);
335        if (!alreadyInterested) broadcastMarkupInterest('join', interest);
336    }
337    callbacks.add(callback);
338    let disposed = false;
339    return { dispose() {
340        if (disposed) return;
341        disposed = true;
342        const current = localMarkupRegisterSubscriptions.get(key);
343        if (!current) return;
344        current.delete(callback);
345        if (current.size === 0) {
346            localMarkupRegisterSubscriptions.delete(key);
347            const interest = { partitionKey, projectId: numericProjectId };
348            if (!localMarkupSubscriptions.has(key)) broadcastMarkupInterest('leave', interest);
349            leaveMarkupIfUnwanted(interest);
350        }
351    } };
352}
353
354// =============================================================================
355// INTERNAL
356// =============================================================================
357
358function dispatchLocal(data) {
359    const threadId = data?.threadId ?? data?.ThreadId;
360    if (!threadId) return;
361    const callbacks = localSubscriptions.get(threadId);
362    if (!callbacks) return;
363    for (const cb of callbacks) {
364        try { cb(data); } catch (e) {
365            console.error('[TaskGroupSync] Subscriber callback failed:', e);
366        }
367    }
368}
369
370function dispatchAndRelay(data) {
371    dispatchLocal(data);
372    // Relay to other tabs (they aren't on the connection)
373    CrossTabSync.broadcast('taskgroup:event', data);
374}
375
376function dispatchMarkupLocal(payload) {
377    const partitionKey = payload?.partitionKey;
378    const data = payload?.data;
379    const projectId = Number(data?.projectId ?? data?.ProjectId);
380    if (!partitionKey || !projectId || partitionKey !== currentPartitionKey()) return;
381    const callbacks = localMarkupSubscriptions.get(markupInterestKey(partitionKey, projectId));
382    if (!callbacks) return;
383    for (const cb of callbacks) {
384        try { cb(data); } catch (e) {
385            console.error('[TaskGroupSync] Markup processing subscriber failed:', e);
386        }
387    }
388}
389
390function dispatchMarkupAndRelay(data) {
391    const partitionKey = currentPartitionKey();
392    if (!partitionKey) return;
393    const payload = { partitionKey, data };
394    dispatchMarkupLocal(payload);
395    CrossTabSync.broadcast('markup-processing-event', payload);
396}
397
398function dispatchMarkupRegisterLocal(payload) {
399    const partitionKey = payload?.partitionKey;
400    const data = payload?.data;
401    const projectId = Number(data?.projectId ?? data?.ProjectId);
402    if (!partitionKey || !projectId || partitionKey !== currentPartitionKey()) return;
403    const callbacks = localMarkupRegisterSubscriptions.get(markupInterestKey(partitionKey, projectId));
404    if (!callbacks) return;
405    for (const cb of callbacks) {
406        try { cb(data); } catch (e) { console.error('[TaskGroupSync] Markup register subscriber failed:', e); }
407    }
408}
409
410function dispatchMarkupRegisterAndRelay(data) {
411    const partitionKey = currentPartitionKey();
412    if (!partitionKey) return;
413    const payload = { partitionKey, data };
414    dispatchMarkupRegisterLocal(payload);
415    CrossTabSync.broadcast('markup-register-event', payload);
416}
417
418function dispatchActivityLocal(payload) {
419    const partitionKey = payload?.partitionKey;
420    const data = payload?.data;
421    const projectId = positiveSafeInteger(data?.projectId ?? data?.ProjectId);
422    if (!partitionKey || !projectId || partitionKey !== currentPartitionKey()) return;
423    const subscriptions = localActivitySubscriptions.get(activityInterestKey(partitionKey, projectId));
424    if (!subscriptions) return;
425    for (const record of subscriptions.values()) {
426        if (!isCurrentActivitySubscription(record)) continue;
427        try { record.callback(data); } catch (error) {
428            console.error('[TaskGroupSync] Project Activity subscriber failed:', error);
429        }
430    }
431}
432
433function dispatchActivityAndRelay(data) {
434    if (!isLeaderAndConnected()) return;
435    const partitionKey = currentPartitionKey();
436    const projectId = positiveSafeInteger(data?.projectId ?? data?.ProjectId);
437    if (!partitionKey || !projectId) return;
438    const payload = { partitionKey, data };
439    dispatchActivityLocal(payload);
440    CrossTabSync.broadcast('project-activity-event', payload);
441}
442
443function isLeaderAndConnected() {
444    return CrossTabSync.isThisTabLeader() && isConnected();
445}
446
447function joinIfLeader(threadId) {
448    if (!isLeaderAndConnected()) return;
449    getNotificationHubClient().joinTaskGroup(threadId);
450}
451
452async function joinMarkupIfLeader(interest) {
453    if (!isLeaderAndConnected() || interest.partitionKey !== currentPartitionKey()) return;
454    const key = markupInterestKey(interest.partitionKey, interest.projectId);
455    const attempt = nextMarkupJoinAttempt();
456    markupJoinAttempts.set(key, attempt);
457    const joined = await getNotificationHubClient().joinMarkupProcessingGroup(interest.projectId);
458    if (!isLeaderAndConnected()
459        || interest.partitionKey !== currentPartitionKey()
460        || markupJoinAttempts.get(key) !== attempt
461        || !isMarkupWanted(interest)) return;
462    if (joined === false) {
463        const payload = {
464            partitionKey: interest.partitionKey,
465            data: { projectId: interest.projectId, unavailable: true }
466        };
467        dispatchMarkupLocal(payload);
468        CrossTabSync.broadcast('markup-processing-event', payload);
469    }
470}
471
472function joinActivityIfLeader(interest) {
473    const numericProjectId = positiveSafeInteger(interest?.projectId);
474    if (!numericProjectId || !isLeaderAndConnected() || interest.partitionKey !== currentPartitionKey()) {
475        return Promise.resolve(false);
476    }
477    const normalized = { partitionKey: interest.partitionKey, projectId: numericProjectId };
478    const key = activityInterestKey(normalized.partitionKey, normalized.projectId);
479    return queueActivityTransition(key, async () => {
480        if (!isLeaderAndConnected()
481            || normalized.partitionKey !== currentPartitionKey()
482            || !isActivityWanted(normalized)) return false;
483
484        const admissionToken = currentActivityAdmissionToken();
485        if (activityGroupStates.get(key)?.admissionToken === admissionToken) {
486            try {
487                await getNotificationHubClient().invoke('RenewProjectActivityGroup', normalized.projectId);
488                publishActivityReady(normalized, admissionToken);
489                return true;
490            } catch (error) {
491                activityGroupStates.delete(key);
492                console.debug('[TaskGroupSync] Could not renew Project Activity group:', error);
493                return false;
494            }
495        }
496
497        const attempt = ++activityJoinAttemptSequence;
498        activityJoinAttempts.set(key, attempt);
499        let joined = false;
500        try {
501            await getNotificationHubClient().invoke('JoinProjectActivityGroup', normalized.projectId);
502            joined = true;
503        } catch (error) {
504            console.debug('[TaskGroupSync] Could not join Project Activity group:', error);
505        }
506        if (!joined
507            || !isLeaderAndConnected()
508            || normalized.partitionKey !== currentPartitionKey()
509            || currentActivityAdmissionToken() !== admissionToken
510            || activityJoinAttempts.get(key) !== attempt
511            || !isActivityWanted(normalized)) return false;
512
513        activityGroupStates.set(key, { admissionToken });
514        publishActivityReady(normalized, admissionToken);
515        return true;
516    });
517}
518
519function leaveActivityIfUnwanted(interest) {
520    const normalized = {
521        partitionKey: interest.partitionKey,
522        projectId: positiveSafeInteger(interest.projectId),
523    };
524    if (!normalized.projectId || isActivityWanted(normalized)) return;
525    const key = activityInterestKey(normalized.partitionKey, normalized.projectId);
526    activityJoinAttempts.delete(key);
527    activityGroupStates.delete(key);
528    void queueActivityTransition(key, async () => {
529        if (isActivityWanted(normalized)
530            || !isLeaderAndConnected()
531            || normalized.partitionKey !== currentPartitionKey()) return false;
532        try {
533            await getNotificationHubClient().invoke('LeaveProjectActivityGroup', normalized.projectId);
534        } catch (error) {
535            console.debug('[TaskGroupSync] Could not leave Project Activity group:', error);
536            return false;
537        }
538        if (isActivityWanted(normalized)) void joinActivityIfLeader(normalized);
539        return true;
540    });
541}
542
543function queueActivityTransition(key, work) {
544    const previous = activityTransitions.get(key) || Promise.resolve();
545    const pending = previous.then(work, work);
546    const settled = pending.then(() => undefined, () => undefined);
547    activityTransitions.set(key, settled);
548    settled.finally(() => {
549        if (activityTransitions.get(key) === settled) activityTransitions.delete(key);
550    });
551    return pending;
552}
553
554function isActivityWanted(interest) {
555    const key = activityInterestKey(interest.partitionKey, interest.projectId);
556    return localActivitySubscriptions.get(key)?.size > 0
557        || remoteActivityInterests.get(key)?.subscriptions.size > 0;
558}
559
560function publishActivityReady(interest, admissionToken) {
561    const key = activityInterestKey(interest.partitionKey, interest.projectId);
562    for (const record of localActivitySubscriptions.get(key)?.values() || []) {
563        admitLocalActivityReady(record, admissionToken);
564    }
565    for (const [subscriptionId, remote] of remoteActivityInterests.get(key)?.subscriptions || []) {
566        CrossTabSync.broadcast('project-activity-ready', {
567            partitionKey: interest.partitionKey,
568            projectId: interest.projectId,
569            tabId: remote.tabId,
570            subscriptionId,
571            admissionToken,
572        });
573    }
574}
575
576function receiveActivityReady(payload) {
577    const { partitionKey, projectId, tabId, subscriptionId, admissionToken } = payload || {};
578    const numericProjectId = positiveSafeInteger(projectId);
579    if (!partitionKey || !numericProjectId || !subscriptionId || !admissionToken
580        || tabId !== CrossTabSync.getTabId() || partitionKey !== currentPartitionKey()) return;
581    const record = localActivitySubscriptions
582        .get(activityInterestKey(partitionKey, numericProjectId))
583        ?.get(subscriptionId);
584    if (record) admitLocalActivityReady(record, admissionToken);
585}
586
587function admitLocalActivityReady(record, admissionToken) {
588    if (!isCurrentActivitySubscription(record) || record.lastReadyToken === admissionToken) return;
589    record.lastReadyToken = admissionToken;
590    try { record.onReady?.({ projectId: record.projectId, admissionToken }); } catch (error) {
591        console.error('[TaskGroupSync] Project Activity ready callback failed:', error);
592    }
593}
594
595function isCurrentActivitySubscription(record) {
596    if (record.signal?.aborted || record.partitionKey !== currentPartitionKey()) return false;
597    return localActivitySubscriptions
598        .get(activityInterestKey(record.partitionKey, record.projectId))
599        ?.get(record.subscriptionId) === record;
600}
601
602function invalidateActivityConnection() {
603    activityConnectionGeneration += 1;
604    activityGroupStates.clear();
605    activityJoinAttempts.clear();
606}
607
608function currentActivityAdmissionToken() {
609    return `${CrossTabSync.getTabId()}:${activityConnectionGeneration}`;
610}
611
612function leaveMarkupIfUnwanted(interest) {
613    const key = markupInterestKey(interest.partitionKey, interest.projectId);
614    if (isMarkupWanted(interest)) return;
615    markupJoinAttempts.delete(key);
616    if (!isLeaderAndConnected() || interest.partitionKey !== currentPartitionKey()) return;
617    getNotificationHubClient().leaveMarkupProcessingGroup(interest.projectId);
618}
619
620function isMarkupWanted(interest) {
621    const key = markupInterestKey(interest.partitionKey, interest.projectId);
622    return localMarkupSubscriptions.has(key)
623        || localMarkupRegisterSubscriptions.has(key)
624        || remoteMarkupInterests.get(key)?.tabs.size > 0;
625}
626
627function nextMarkupJoinAttempt() {
628    markupJoinAttemptSequence += 1;
629    return markupJoinAttemptSequence;
630}
631
632function leaveIfUnwanted(threadId) {
633    if (!isLeaderAndConnected()) return;
634    // Still wanted by this tab or any live remote tab? Keep the membership.
635    if (localSubscriptions.has(threadId)) return;
636    if (remoteInterests.get(threadId)?.size > 0) return;
637    getNotificationHubClient().leaveTaskGroup(threadId);
638}
639
640function rejoinAll() {
641    if (!isLeaderAndConnected()) return;
642    const wanted = new Set([...localSubscriptions.keys(), ...remoteInterests.keys()]);
643    for (const threadId of wanted) {
644        getNotificationHubClient().joinTaskGroup(threadId);
645    }
646    for (const key of new Set([...localMarkupSubscriptions.keys(), ...localMarkupRegisterSubscriptions.keys(), ...remoteMarkupInterests.keys()])) {
647        const interest = remoteMarkupInterests.get(key) || parseMarkupInterestKey(key);
648        if (interest) joinMarkupIfLeader(interest);
649    }
650    for (const key of new Set([...localActivitySubscriptions.keys(), ...remoteActivityInterests.keys()])) {
651        const interest = remoteActivityInterests.get(key) || parseActivityInterestKey(key);
652        if (interest) void joinActivityIfLeader(interest);
653    }
654}
655
656function broadcastInterests() {
657    const tabId = CrossTabSync.getTabId();
658    for (const threadId of localSubscriptions.keys()) {
659        CrossTabSync.broadcast('taskgroup:join', { threadId, tabId });
660    }
661    for (const key of localMarkupSubscriptions.keys()) {
662        const interest = parseMarkupInterestKey(key);
663        if (interest) {
664            broadcastMarkupInterest('join', interest);
665            joinMarkupIfLeader(interest);
666        }
667    }
668    for (const key of localMarkupRegisterSubscriptions.keys()) {
669        if (localMarkupSubscriptions.has(key)) continue;
670        const interest = parseMarkupInterestKey(key);
671        if (interest) {
672            broadcastMarkupInterest('join', interest);
673            joinMarkupIfLeader(interest);
674        }
675    }
676    for (const subscriptions of localActivitySubscriptions.values()) {
677        for (const record of subscriptions.values()) {
678            broadcastActivityInterest('join', record);
679            void joinActivityIfLeader(record);
680        }
681    }
682}
683
684function pruneRemoteInterests() {
685    const cutoff = Date.now() - REMOTE_TTL_MS;
686    for (const [threadId, tabs] of remoteInterests) {
687        for (const [tabId, lastSeen] of tabs) {
688            if (lastSeen < cutoff) tabs.delete(tabId);
689        }
690        if (tabs.size === 0) {
691            remoteInterests.delete(threadId);
692            leaveIfUnwanted(threadId);
693        }
694    }
695    for (const [key, interest] of remoteMarkupInterests) {
696        for (const [tabId, lastSeen] of interest.tabs) {
697            if (lastSeen < cutoff) interest.tabs.delete(tabId);
698        }
699        if (interest.tabs.size === 0) {
700            remoteMarkupInterests.delete(key);
701            leaveMarkupIfUnwanted(interest);
702        }
703    }
704    for (const [key, interest] of remoteActivityInterests) {
705        for (const [subscriptionId, remote] of interest.subscriptions) {
706            if (remote.lastSeen < cutoff) interest.subscriptions.delete(subscriptionId);
707        }
708        if (interest.subscriptions.size === 0) {
709            remoteActivityInterests.delete(key);
710            leaveActivityIfUnwanted(interest);
711        }
712    }
713}
714
715function currentPartitionKey() {
716    return offlineStore.getActivePartition()?.key || null;
717}
718
719function markupInterestKey(partitionKey, projectId) {
720    return `${partitionKey}|${Number(projectId)}`;
721}
722
723function parseMarkupInterestKey(key) {
724    const splitAt = key.lastIndexOf('|');
725    if (splitAt <= 0) return null;
726    const partitionKey = key.slice(0, splitAt);
727    const projectId = Number(key.slice(splitAt + 1));
728    return projectId ? { partitionKey, projectId } : null;
729}
730
731function broadcastMarkupInterest(action, interest) {
732    CrossTabSync.broadcast('markup-processing-interest', {
733        action,
734        partitionKey: interest.partitionKey,
735        projectId: interest.projectId,
736        tabId: CrossTabSync.getTabId()
737    });
738}
739
740function activityInterestKey(partitionKey, projectId) {
741    return `${partitionKey}|${Number(projectId)}`;
742}
743
744function parseActivityInterestKey(key) {
745    const splitAt = key.lastIndexOf('|');
746    if (splitAt <= 0) return null;
747    const projectId = positiveSafeInteger(key.slice(splitAt + 1));
748    return projectId ? { partitionKey: key.slice(0, splitAt), projectId } : null;
749}
750
751function positiveSafeInteger(value) {
752    const number = Number(value);
753    return Number.isSafeInteger(number) && number > 0 ? number : null;
754}
755
756function broadcastActivityInterest(action, record) {
757    CrossTabSync.broadcast('project-activity-interest', {
758        action,
759        partitionKey: record.partitionKey,
760        projectId: record.projectId,
761        tabId: CrossTabSync.getTabId(),
762        subscriptionId: record.subscriptionId,
763    });
764}
765
766/**
767 * Tear down (logout). Page modules use their own dispose() — this is for app teardown.
768 */
769export function destroyTaskGroupSync() {
770    pubsubSubscriptions.forEach(unsub => unsub());
771    pubsubSubscriptions = [];
772    if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
773    localSubscriptions.clear();
774    remoteInterests.clear();
775    localMarkupSubscriptions.clear();
776    localMarkupRegisterSubscriptions.clear();
777    remoteMarkupInterests.clear();
778    markupJoinAttempts.clear();
779    localActivitySubscriptions.clear();
780    remoteActivityInterests.clear();
781    activityGroupStates.clear();
782    activityJoinAttempts.clear();
783    activityTransitions.clear();
784    activityConnectionGeneration += 1;
785    initialized = false;
786}
787
788export default { initTaskGroupSync, subscribeTaskUpdates, subscribeMarkupProcessingUpdates, subscribeMarkupRegisterUpdates, subscribeProjectActivityUpdates, destroyTaskGroupSync };

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.