Skip to content

feat: added support for breakouts to Locus hash trees (5k webinars)#4775

Merged
marcin-bazyl merged 9 commits intowebex:nextfrom
marcin-bazyl:spark-758861
Mar 27, 2026
Merged

feat: added support for breakouts to Locus hash trees (5k webinars)#4775
marcin-bazyl merged 9 commits intowebex:nextfrom
marcin-bazyl:spark-758861

Conversation

@marcin-bazyl
Copy link
Copy Markdown
Collaborator

@marcin-bazyl marcin-bazyl commented Mar 13, 2026

COMPLETES #SPARK-758861

This pull request addresses

Breakouts support for 5k webinars. Locus wiki explaining how it should work:
https://sqbu-github.cisco.com/pages/WebExSquared/locus/guides/convergedWebinar5k/breakoutMoves.html

by making the following changes

Changed LocusInfo so that it now has a map of HashTreeParsers instead of just a single parser. We have a separate parser for each locusUrl now, so when we move between breakouts and locusUrl changes, we change the parsers. The old one is stopped. It can be resumed if user goes back. Right now stop() clears all the hash trees and resume() starts from scratch, because that's what Locus team wants clients to do. We can potentially change that if needed in the future and keep the old hash trees. We may still be getting some messages for the old locusUrl, so it's good to keep the old HashTreeParser and route the messages to it and the parser just ignores them when it's stopped. Otherwise, if there was no parser to handle them, the messages could potentially cause a creation of a new meeting object.

When testing, you will see that when moving between sessions there is a delay before the participant list is populated. This is because Locus doesn't send us participant list in the 1st message for the new session and we rely on the sync algorithm, so the messages arrive a bit later or if they don't arrive, we do the sync. This is by design. Locus team have been informed that the delay is unacceptably long right now and they are considering various solutions to this, but they want the client implementation to remain as is - so no initial sync, no proactive getting of participant list.

All unit tests were created solely by AI, I've looked through them, they're not perfect, but pretty good, I think good enough.

Change Type

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Tooling change
  • Internal code refactor

The following scenarios were tested

unit tests

The GAI Coding Policy And Copyright Annotation Best Practices

  • GAI was not used (or, no additional notation is required)
  • Code was generated entirely by GAI
  • GAI was used to create a draft that was subsequently customized or modified
  • Coder created a draft manually that was non-substantively modified by GAI (e.g., refactoring was performed by GAI on manually written code)
  • Tool used for AI assistance (GitHub Copilot / Other - specify)
    • Github Copilot
    • Other - Please Specify
  • This PR is related to
    • Feature
    • Defect fix
    • Tech Debt
    • Automation

I certified that

  • I have read and followed contributing guidelines

  • I discussed changes with code owners prior to submitting this pull request

  • I have not skipped any automated checks

  • All existing and new tests passed

  • I have updated the documentation accordingly


Make sure to have followed the contributing guidelines before submitting.

@aws-amplify-us-east-2
Copy link
Copy Markdown

This pull request is automatically being deployed by Amplify Hosting (learn more).

Access this pull request here: https://pr-4775.d3m3l2kee0btzx.amplifyapp.com

@marcin-bazyl marcin-bazyl added the validated If the pull request is validated for automation. label Mar 20, 2026
this.visibleDataSets = (
cloneDeep(options.metadata?.visibleDataSets || []) as VisibleDataSetInfo[]
).filter((vds) => !this.isExcludedDataSet(vds.name));
this.setVisibleDataSets(options.metadata?.visibleDataSets || [], dataSets);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extracted this block of code into a setVisibleDataSets() method, because I needed to have the same logic in resume() method

locus: LocusDTO; // this LocusDTO here might not be the full one (for example it won't have all the participants, but it should have self)
metadata?: Metadata;
}
| LocusDTO; // when we invoke APIs on the whole Locus like "mute all" backend returns the whole Locus in the response like this
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the fact that some API responses don't have dataSets and metadata but the body is just a locus object is something I found needed handling while working on breakouts, it's not strictly related to breakouts, but I've kept it in this PR.


export type HashTreeParserEntry = {
parser: HashTreeParser;
replacedAt?: string;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to store replacedAt to handle cases where user moves: BO 1 -> BO 2 -> BO 1 again - we need to check that replacedAt is newer when moving back to BO 1

export type HashTreeParserEntry = {
parser: HashTreeParser;
replacedAt?: string;
initializedFromHashTree: boolean;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this flag is needed, because we need to clear Locus when switching parsers. I was debating whether that state belonged more into HashTreeParser itself or LocusInfo, but decided it belongs in LocusInfo, because in some cases (like when we get full locus) HashTreeParser will not know that LocusInfo has all the data already initialized

initialLocus: {
locus: null,
dataSets: [], // empty, because they will be populated in initializeFromMessage() call // dataSets: data.hashTreeMessage.dataSets,
dataSets: data.hashTreeMessage.dataSets,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to pass dataSets from message now here, because they're needed by HashTreeParser constructor when it sets visible data sets - see HashTreeParser.setVisibleDataSets(). I think this is a cleaner way of dealing with edge cases where we get messages from Locus with visible data sets that are not listed in dataSets list

this.participants?.forEach((participant) => {
// participant.htMeta is set only for hash tree based locus
if (participant.htMeta?.elementId.id) {
if (typeof participant.htMeta?.elementId.id === 'number') {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a fix for a bug I found during breakout testing - we were not handling the case of participant's id being equal to zero correctly here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: should we be checking for undefined instead of checking the type of the id?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm not sure, this was suggested by ai and I kind of like the fact that we're checking that it's a number, so if the id was null or anything unexpected it would also fail the check

const associateBreakoutLocus = this.breakoutLocusForHandleLater[existIndex];
this.handleLocusEvent({
eventType: LOCUSEVENT.SDK_NO_EVENT,
eventType: LOCUSEVENT.SDK_LOCUS_FROM_SYNC_MEETINGS,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here I've realised that breakoutLocusForHandleLater stores locus objects we got from /sync call so we should set the eventType accordingly

* @memberof Meeting
*/

export function storeEventForDebugging(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is something I've been using when testing, it's very handy, because it allows me to easily see all received messages (both from LLM and mercury) from a specific point in time (for example after moving to a breakout - I just do window.locusEvents=[] just before initiating the breakout move). I forgot to remove it when raising the PR, but now I'm thinking that maybe it could be useful for others too, so maybe leave it here?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I think it is fine to leave it here for now. I agree, useful for debugging

@marcin-bazyl marcin-bazyl marked this pull request as ready for review March 23, 2026 09:43
@marcin-bazyl marcin-bazyl requested review from a team as code owners March 23, 2026 09:44
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8664b3fb0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1187 to 1190
if (this.hashTreeParsers.size > 0) {
this.handleHashTreeMessage(
meeting,
data.eventType,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle breakout loci from syncMeetings in hash-tree mode

When a meeting is created from GET /loci, it already has a hashTreeParsers entry. checkHandleBreakoutLocus() now replays the associated breakout DTO as SDK_LOCUS_FROM_SYNC_MEETINGS, but this branch still routes every subsequent event through handleHashTreeMessage(), which immediately rejects anything except HASH_TREE_DATA_UPDATED. On reconnect/refresh while the user is already in breakout, the replayed breakout locus from the sync response is therefore dropped, so the SDK stays on the main-session state until some later Mercury/LLM move event arrives.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've created https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-790957 to deal with this in a separate jira

Comment on lines +1113 to +1117
if (!hashTreeParserEntry.initializedFromHashTree) {
// this is the first time we're getting an update for this locusUrl,
// so it's probably a move to/from breakout. We need to start from a clean state,
// so empty locus and we rely on Locus giving us sufficient data in the updates to populate it.
LoggerProxy.logger.info(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Seed the first switched-parser update with the new locus URL

On the first update for a newly created or resumed parser, this path intentionally starts from an empty locus and forces member replacement. The problem is that the empty state does not carry locusUrl, and hash-tree messages are often partial (the meeting creation path above explicitly notes they may contain only self). If that first post-move update has no Locus object, updateLocusUrl() never fires, so the meeting keeps the previous session URL and follow-up operations like leave, lock, reactions, or token refresh still target the old breakout until a later full locus update happens.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine, because Locus is supposed to always send us the main LOCUS object with SELF in the 1st message for the new breakout session

* @param {string} deviceUrl - The URL of the user's device
* @returns {any} The replace information if available, otherwise undefined
*/
function getReplaceInfoFromSelf(self: any, deviceUrl: string) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: could we add ReplacesInfo as the return type?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@@ -513,10 +625,11 @@ export default class LocusInfo extends EventsScope {
'Locus-info:index#initialSetup --> creating HashTreeParser from message'
);
// first create the HashTreeParser, but don't initialize it with any data yet
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: this is comment still valid?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes it's still valid

dataSet.heartbeatWatchdogTimer = undefined;
}
});
this.state = 'stopped';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: move to stop() function

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

* @param {Meeting} meeting - The meeting object
* @param {eventType} eventType - The event type
* @param {HashTreeMessage} message incoming hash tree message
* @param {any} replaces - if this message indicates that a new locusUrl replaces some old locusUrl, this object contains info about the old locus
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: replaces is not part of the parameters

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

this.participants?.forEach((participant) => {
// participant.htMeta is set only for hash tree based locus
if (participant.htMeta?.elementId.id) {
if (typeof participant.htMeta?.elementId.id === 'number') {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: should we be checking for undefined instead of checking the type of the id?

onDeltaLocus(locus: any) {
const isReplaceMembers = ControlsUtils.isNeedReplaceMembers(this.controls, locus.controls);
const isReplaceMembers =
locus.jsSdkMeta?.forceReplaceMembers !== undefined
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if forceReplaceMembers is set to false, isReplaceMembers always be false and it won't check isNeedReplaceMembers. Is this expected?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that's the intention - if the forceReplaceMembers field is set, it should override whatever comes from ControlsUtils.isNeedReplaceMembers() - basically ControlsUtils.isNeedReplaceMembers() shouldn't be even called

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c02016398

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37e5f89253

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 154e75fa53

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af67d7934f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

stateElementsMessage?: HashTreeMessage;
}
) {
if ((window as any)?.locusEvents) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard debug event storage with typeof window

Fresh evidence: this implementation uses (window as any)?.locusEvents, but optional chaining does not protect against an undeclared global identifier, so in Node/SSR/test environments resolving window still throws ReferenceError before locus processing continues. This turns an optional debug path into a runtime failure whenever non-browser clients receive hash-tree events.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a config entry for this, so only customers who use SDK in the browser and want to debug the hash tree events (so in essence only our web app) will use this

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e347f03d3d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// the message matches a stopped parser, we need to check if maybe this is a new "replacement" and we need to re-activate the parser
// this happens when you move from breakout A -> breakout B -> back to breakout A
if (replaces) {
if (replaces.replacedAt > (entry.replacedAt || '')) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve replacement ordering before resuming stopped parser

When a parser is stopped without recording replacedAt (the new-parser path only logs a warning if replaces is missing), this resume gate compares against '', so any later delayed message from the old locus with a non-empty replaces.replacedAt is treated as newer and reactivates the old parser. In out-of-order breakout move traffic, that can switch the meeting back to a stale locus and cause subsequent state updates/actions to target the wrong session.

Useful? React with 👍 / 👎.

@marcin-bazyl marcin-bazyl merged commit 0b705ea into webex:next Mar 27, 2026
12 checks passed
@github-actions
Copy link
Copy Markdown

🎉 Your changes are now available!
Released in: v3.12.0-next.1
📖 View full changelog →
Packages Updated Version
webex 3.12.0-next.1
@webex/plugin-meetings 3.12.0-next.1

Thank you for your contribution!
🤖 This is an automated message. For questions, please refer to the documentation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

validated If the pull request is validated for automation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants