jam-cloud/agent-tasks/client-simulator/async-bridge-spike/progress.md

23 KiB

Async Bridge Spike Progress

Goal

Determine whether frontend code can support await context.jamClient.method() for a browser/web-client implementation while preserving compatibility with existing synchronous native window.jamClient calls.

Questions

  • What is the current source of truth for native-vs-browser mode (gon.isNativeClient, window.jamClient presence, jamClient.IsNativeClient(), etc.)?
  • Where is window.jamClient initialized/replaced (real native bridge vs fake/intercepted client)?
  • Can await be introduced incrementally at callsites without breaking sync native behavior?
  • Which core session-flow call chains would require async propagation first?

Initial Plan

  • Identify existing native/browser detection logic and init path(s)
  • Find current jamClient interception/test hooks we can reuse for call-pattern recording
  • Select one session-related call chain as the async spike candidate
  • Document recommended compatibility pattern (native sync + web async via Promise-compatible calls)

Notes

  • Key assumption to validate: await on a synchronous native return value is safe (it should resolve immediately), allowing one code path if methods are called through await in async functions.
  • Confirmed locally with Node runtime check: await on plain values/objects resolves immediately.

Findings (2026-02-25)

  • Native/browser mode detection already exists in multiple layers:
  • ClientHelper#is_native_client? sets gon.isNativeClient, including the forced mode via cookie act_as_native_client.
  • homeScreen.js toggles act_as_native_client with Ctrl+Shift+0 (keyCode == 48) when gon.allow_force_native_client is enabled.
  • Runtime behavior also relies on window.jamClient presence and context.jamClient.IsNativeClient().
  • JK.initJamClient(app) in web/app/assets/javascripts/utils.js is the main installation point:
  • if no window.jamClient, it installs JK.FakeJamClient.
  • this is the natural replacement point for a future JK.WebJamClient in "pretend native/session-capable browser mode".
  • Existing interception hook for recording exists in JK.initJamClient(app) (currently disabled under else if(false)), which wraps window.jamClient methods and logs call timing/returns.
  • Async spike candidate call chain selected: session entry (SessionStore / session.js -> sessionUtils.SessionPageEnter() -> context.jamClient.SessionPageEnter()).
  • This chain is a good spike because the return value is used immediately by guardAgainstActiveProfileMissing, so it exposes async propagation requirements early.
  • Current blast-radius observation (session entry path):
  • SessionStore.js.coffee and legacy session.js both call sessionUtils.SessionPageEnter() synchronously and immediately pass the result to gearUtils.guardAgainstActiveProfileMissing(...).
  • guardAgainstActiveProfileMissing(...) expects a plain object (backendInfo.error, backendInfo.reason), not a Promise.
  • Therefore the first migration step cannot be "just make SessionPageEnter() async" without adapting both callers and guard handling.
  • CoffeeScript asset pipeline does not currently support real async/await syntax in these files:
  • local compile checks with coffee-script gem compile await as a normal identifier call (await(y)), producing invalid JS for native async/await semantics.
  • implication: for CoffeeScript-heavy paths, prefer Promise/jQuery Deferred adapters first, not direct await syntax.
  • Confirmed from origin/promised_based_api_interation: the team previously worked around this in SessionStore.js.coffee by embedding raw JavaScript via CoffeeScript backticks (e.g. onJoinSession: `async function(sessionId) { ... }` and await context.jamClient... inside).
  • That branch also converted the session-entry path (onJoinSessionDone, joinSession, waitForSessionPageEnterDone) to async this way, which directly validates the spike target and migration shape.
  • Confirmed local migration tool viability:
  • npx decaffeinate --version -> v8.1.4
  • Dry-run conversion of SessionStore.js.coffee produced structurally usable output (method/object style preserved closely enough for follow-on edits).
  • Decaffeinate outputs modern JS syntax, so the correct target in this repo is .es6 (to stay on the existing sprockets-es6 pipeline) rather than plain .js.

Changes Applied (2026-02-25)

  • Migrated web/app/assets/javascripts/react-components/stores/SessionStore.js.coffee to web/app/assets/javascripts/react-components/stores/SessionStore.es6 using decaffeinate.
  • Removed the CoffeeScript source file for SessionStore.
  • Basic syntax parse check passed by copying the .es6 file to a temporary .js filename and running node --check (Node does not natively parse .es6 extension files directly).
  • Added parser-safe dual bundle delivery scaffolding for the /client layout:
  • web/app/assets/javascripts/client_legacy.js
  • web/app/assets/javascripts/client_modern.js
  • web/app/views/layouts/client.html.erb now selects bundle server-side via legacy_qt_webkit_client?
  • web/app/helpers/client_helper.rb#legacy_qt_webkit_client? uses UA-only detection (ignores forced-native cookie)
  • Added asset precompile entries for both bundles in web/config/application.rb and web/config/environments/test.rb
  • Split SessionStore loading by bundle:
  • restored legacy web/app/assets/javascripts/react-components/stores/SessionStore.js.coffee (legacy/default path)
  • moved modern JS-converted store to web/app/assets/javascripts/react-components/stores/SessionStoreModern.es6
  • added web/app/assets/javascripts/react-components_modern.js to load SessionStoreModern explicitly and avoid require_directory ./react-components/stores pulling the legacy store
  • added web/app/assets/javascripts/application_client_modern.js (copy of application.js with react-components_modern)
  • web/app/assets/javascripts/client_modern.js now requires application_client_modern
  • Added //= require babel/polyfill to web/app/assets/javascripts/application_client_modern.js so Babel 5 async/await transpilation has regeneratorRuntime available in the modern client bundle.
  • Converted the modern-only session-entry path in web/app/assets/javascripts/react-components/stores/SessionStoreModern.es6 to native async/await:
  • onJoinSession
  • onJoinSessionDone (new helper extracted from the nested deferred chain)
  • await now wraps mixed sync/thenable results (rest, jQuery Deferreds, and sync SessionPageEnter() values) in the modern path only.
  • Continued the modern-only async conversion in SessionStoreModern.es6:
  • waitForSessionPageEnterDone -> async
  • joinSession -> async
  • Preserved this.joinDeferred as the original jQuery Deferred/XHR object so existing .state() checks and .done() listeners still work.
  • Fixed audio_latency retrieval to await FTUEGetExpectedLatency() before reading .latency.
  • Added mode-aware browser jamClient installation seam in web/app/assets/javascripts/utils.js:
  • if no native window.jamClient and gon.isNativeClient && JK.WebJamClient exists, install WebJamClient
  • otherwise fall back to FakeJamClient (current behavior)
  • Added initial web/app/assets/javascripts/webJamClient.js shim:
  • delegates to FakeJamClient (method/property passthrough) so the seam is exercised without changing behavior yet
  • exposes IsWebClient() marker and retains conservative IsNativeClient() -> false for now
  • keeps __delegate handle to support incremental method replacement/testing
  • Loaded webJamClient.js from web/app/assets/javascripts/everywhere/everywhere.js before JK.initJamClient(...) runs.
  • Expanded WebJamClient with first real overrides and internal state bookkeeping:
  • callback registrations (SessionRegisterCallback, RegisterRecordingCallbacks, RegisterSessionJoinLeaveRequestCallBack)
  • session lifecycle (SessionPageEnter, SessionPageLeave, JoinSession, LeaveSession)
  • connection refresh rate tracking (SessionSetConnectionStatusRefreshRate)
  • GetWebClientDebugState() for inspection during manual testing
  • Added minimal local-media bootstrap placeholders in WebJamClient (disabled by default):
  • optional getUserMedia({audio:true}) auto-start behind flags only
  • tracks local media status in debug state; stops tracks on page/session leave transitions
  • Instrumentation hooks implemented (disabled by default, ready for manual recording sessions):
  • records jamClient calls/returns/throws + thenable resolve/reject events + session state transitions inside WebJamClient
  • clearly gated by:
    • window.JK_WEB_CLIENT_RECORDING_ENABLED = true, or
    • localStorage['jk.webClient.recording'] = '1', or
    • gon.web_client_recording_enabled
  • recorder control methods:
    • jamClient.WebClientRecorderEnable()
    • jamClient.WebClientRecorderDisable()
    • jamClient.WebClientRecorderClear()
    • jamClient.WebClientRecorderGetLog()
  • recorder is intended as TEMP/manual capture support and is easy to disable/remove later.
  • Implemented native QtWebKit bridge call instrumentation in web/app/assets/javascripts/utils.js via adapter wrapping (disabled by default):
  • installs window.jamClientAdapter (does not swap/replace the special QtWebKit window.jamClient object)
  • native adapter wraps and forwards to the real Qt bridge object during JK.initJamClient(...)
  • exposes recorder controls on JK for manual native capture sessions
  • recorder controls on JK:
    • JK.enableNativeJamClientRecording()
    • JK.disableNativeJamClientRecording()
    • JK.clearNativeJamClientRecording()
    • JK.getNativeJamClientRecording()
  • enable auto-recording with:
    • window.JK_NATIVE_CLIENT_RECORDING_ENABLED = true, or
    • localStorage['jk.nativeJamClient.recording'] = '1', or
    • gon.native_client_recording_enabled
  • caveat: wrapping depends on Object.keys(window.jamClient) exposing Qt bridge method keys (the app already had a dormant interceptor using this same mechanism, which is why this approach is viable).
  • Pivoted callsite strategy (per native client constraint): migrated frontend callsites to use jamClientAdapter instead of direct jamClient access so instrumentation does not require replacing the Qt bridge object.
  • Bulk-updated app frontend assets and inline views; remaining direct jamClient references are intentionally limited to utils.js bootstrap/adapter internals and object creation paths.
  • Treat gon.isNativeClient as "session-capable client mode" (includes real native + forced browser emulation).
  • Distinguish actual bridge availability by checking native window.jamClient presence and/or jamClient.IsNativeClient().
  • Replace FakeJamClient installation in JK.initJamClient(app) with mode-aware behavior:
  • real native bridge present: use native window.jamClient
  • gon.isNativeClient == true but no native bridge: install JK.WebJamClient
  • normal browser pages (gon.isNativeClient == false): retain minimal fake/stub behavior as needed (or a reduced shim)
  • Introduce await incrementally on selected jamClient call chains; native sync returns remain compatible under await, but caller functions must become async (or return Promises / deferreds) as propagation proceeds.
  • Split migration strategy by file/runtime:
  • ES6 modules/wrappers can use async/await directly.
  • CoffeeScript/jQuery chains should use a Promise/Deferred normalization helper (e.g., JK.whenJamClientResult(result) / Promise.resolve(result) + bridge to $.Deferred) until those paths are moved out of CoffeeScript.
  • First practical spike implementation should add a parallel method instead of changing existing sync method in place:
  • keep sessionUtils.SessionPageEnter() (sync contract)
  • add sessionUtils.SessionPageEnterDeferred() (or similar) that normalizes sync/async jamClient returns into a $.Deferred
  • update one caller path (prefer SessionStore) behind a feature flag or mode check for WebJamClient
  • Alternative (proven but ugly): use backtick-embedded raw JS async function blocks in SessionStore.js.coffee for the spike.
  • Preferred longer-term direction for maintainability: migrate high-churn CoffeeScript session/store files to JS/ES modules instead of expanding the backtick pattern.
  • SessionStore migration is now done, so the next spike step should use native async in SessionStore.es6 for the session-entry path.
  • Raw async/await remains unsafe for legacy QtWebKit if delivered directly; the new dual bundle switch is the mechanism to keep parser-incompatible syntax out of the legacy client.
  • Next validation needed before introducing async in SessionStoreModern.es6: confirm the sprockets-es6 pipeline used here can parse/emit files containing async/await syntax (or at least pass through for modern browsers).
  • Validation results:
  • babel-transpiler (Babel 5.8 via sprockets-es6) successfully transpiles async/await and emits regeneratorRuntime-based code.
  • SessionStoreModern.es6 passes syntax parse via node --check (on a temporary .js copy).
  • After additional edits, SessionStoreModern.es6 still passes node --check and Babel::Transpiler.transform(...) (BABEL_OK).
  • Rails/Sprockets asset lookup validation (serial run) succeeded for both client bundles:
  • MODERN_FOUND
  • LEGACY_FOUND
  • MODERN_HAS_REGEN
  • MODERN_HAS_SESSIONSTOREMODERN_CODE
  • MODERN_NO_BAD_UNDEFINED_ACTIONS
  • LEGACY_NO_SESSIONSTOREMODERN_REF
  • LEGACY_NO_BAD_UNDEFINED_ACTIONS
  • Practical note: Rails boot in this repo starts websocket/EventMachine side effects, so validation commands should be run serially (parallel boots can conflict on port 6767 and produce false-negative noise).
  • Local JS seam simulation confirmed JK.initJamClient(...) selects WebJamClient when gon.isNativeClient == true, no native window.jamClient is present, and JK.WebJamClient is defined.
  • Local JS runtime simulation validated WebJamClient constructor, recorder controls, session state updates, and debug-state reporting.
  • utils.js native adapter instrumentation patch passes syntax parse (node --check on a temp copy).
  • Full Rails asset compile/runtime validation is still pending; direct rails runner asset probing booted websocket/EventMachine side effects in this repo and was not a clean validation path.
  • Follow-up debugging from legacy client load (console log capture):
  • Legacy QtWebKit was still receiving modern manifest code because application.js has //= require_directory ., and the newly-added top-level manifests (client_modern, application_client_modern, react-components_modern) were accidentally included in the legacy/default bundle.
  • Fixed by moving all new manifests under web/app/assets/javascripts/client_bundles/ and updating layout/precompile entries to use client_bundles/client_legacy and client_bundles/client_modern.
  • Also fixed decaffeinate strict-mode top-level references in SessionStoreModern.es6 (this -> window/context for action bindings and SessionStore export) so Babel "use strict" does not produce undefined.JamTrackActions.
  • Legacy parser/runtime follow-ups from hosted console.log:
  • fb-error / Expected token 'in' traced to Facebook SDK script (connect.facebook.net/.../all.js) using for...of (unsupported by old QtWebKit parser).
  • Initial selective skip in everywhere.js caused UI startup regressions because downstream code expected facebookHelper.deferredLoginStatus() to always return a Deferred.
  • Stabilized by moving disable behavior into facebook_helper.js:
  • FB SDK is now hard-disabled via shouldEnableSdk() -> false (temporary), while still creating/resolving loginStatusDeferred with {status: 'disabled'} so call sites remain functional.
  • promptLogin() now resolves immediately to disabled response when SDK disabled.
  • deferredLoginStatus() now guarantees a non-null resolved Deferred even before initialize().
  • Reverted special init branching in everywhere.js; initialization flow is again normal (facebookHelper.initialize(...)) but helper itself no-ops SDK load safely.
  • Investigated session join 409 (PG::UniqueViolation) and found duplicate participant_create requests with identical payload/session id.
  • Likely cause in modern bundle: both SessionStoreModern and legacy SessionStore can be loaded (via application_client_modern.js require_directory .. pulling top-level react-components.js), leading to duplicate Reflux SessionActions.joinSession handlers.
  • Mitigation added:
  • SessionStoreModern.es6 sets marker window.__JK_USE_MODERN_SESSION_STORE__ = true before creating store.
  • SessionStore.js.coffee now aliases to existing store when marker is set (@SessionStore = if context.__JK_USE_MODERN_SESSION_STORE__ then context.SessionStore else Reflux.createStore(...)) so legacy store does not register duplicate listeners in modern bundle.
  • Kept require_directory .. in modern application manifest to avoid regressing other root-level script dependencies.
  • Added REST-level single-flight guard in JK.Rest.joinSession (web/app/assets/javascripts/jam_rest.js) keyed by session_id|client_id.
  • Duplicate in-flight join calls now reuse the same jqXHR instead of issuing a second POST.
  • Added cleanup on always() to release key after completion.
  • Hardened implementation to avoid mutating caller options object (payload cloned before removing session_id).
  • Added high-signal debug instrumentation for duplicate join investigation:
  • jam_rest.js: join dedupe map moved from per-instance to global shared map (JK.__pendingJoinSessionRequests) because multiple JK.Rest() instances exist.
  • jam_rest.js: logs [join-dedupe] create|reuse|release with dedupe_key, rest_instance_id, and trace_token.
  • SessionStore.js.coffee: logs legacy store load/init/onJoinSession ([session-store] legacy-*) with marker/count context.
  • SessionStoreModern.es6: logs modern store load/init/onJoinSession ([session-store] modern-*) with load-count context.
  • Added extra join-source tracing because duplicate attempt source is still unknown:
  • session_utils.js: logs [join-source] SessionUtils.joinSession with call count + stack.
  • CallbackStore.js.coffee: logs [join-source] generic-callback join_session with payload + stack before dispatching SessionActions.joinSession.
  • This should distinguish UI-initiated join from native/generic-callback initiated join.
  • Root-cause analysis from console logs: duplicate join came from two SessionStore pipelines running (legacy + modern), not from REST payload issues.
  • Why prior marker was insufficient: load-order race when react-components.js is included indirectly (require_directory ..), where legacy SessionStore may load before modern marker is set.
  • Added load-order-independent modern mode flag:
  • client_bundles/session_store_modern_mode.js sets window.__JK_SKIP_LEGACY_SESSION_STORE__ = true.
  • application_client_modern.js now requires this flag before broad includes.
  • SessionStore.js.coffee now no-ops when either __JK_SKIP_LEGACY_SESSION_STORE__ or __JK_USE_MODERN_SESSION_STORE__ is set.
  • Implemented debugging_console_spec.md diversion:
  • Added env-gated gon flag: gon.log_to_server set from ENV['LOG_TO_SERVER'] == '1' in client_helper.rb.
  • Added new API endpoint POST /api/debug_console_logs (ApiDebugConsoleLogsController#create) that writes pretty JSON log dumps to web/tmp/console-logs/YYYYMMDD-HHMMSS-<label>.log.
  • Label sanitization implemented per spec: trim + remove all whitespace/newlines; defaults to log when blank.
  • Added client-side in-memory collector debug_log_collector.js (enabled only when gon.log_to_server):
  • captures REST activity by wrapping jQuery.ajax (rest.request, rest.response, rest.error).
  • exposes JK.DebugLogCollector.push/getBuffer/clear/promptAndUpload.
  • prompts user on EVENTS.SESSION_ENDED for label via prompt(...), uploads to new API, and alerts result.
  • Integrated jam-bridge and websocket capture into collector:
  • utils.js native jamClient adapter pushRecord(...) now forwards events into collector.
  • JamServer.js logs websocket send/receive payloads into collector.
  • Added manifests so collector loads in both bundles:
  • application.js
  • client_bundles/application_client_modern.js
  • Reduced debug UI noise per request:
  • Removed post-upload window.alert popups from debug_log_collector.js (prompt remains; outcomes now recorded in buffer as debug-log-collector.uploaded / debug-log-collector.upload-error).
  • Removed temporary native jamClient console mirroring in utils.js; native bridge instrumentation remains buffer-based.
  • Removed join-dedupe console/logger output from jam_rest.js; join dedupe telemetry now goes to debug buffer only (join-dedupe.create|reuse|release).
  • Fixed debug log file double-encoding in ApiDebugConsoleLogsController.
  • logs payload is now normalized recursively (ActionController::Parameters/arrays/hashes) into plain JSON-friendly structures before writing.
  • Added safe best-effort JSON string parsing for string values that are valid JSON.
  • Added REST request/response correlation IDs in debug collector.
  • rest.request, rest.response, and rest.error now include request_id (rest-<seq>), and response/error include duration_ms.
  • Continued root-cause instrumentation for duplicate join attempts, with all new signals routed into DebugLogCollector buffer:
  • SessionActions.joinSession wrapper now captures both direct call and .trigger(...) invocations (join-source.session-action) with stack.
  • session.js (legacy SessionScreen path) now logs:
    • join-source.session-screen.afterShow
    • join-source.session-screen.afterCurrentUserLoaded
    • join-source.session-screen.sessionModel.joinSession
  • sessionModel.js now logs:
    • join-source.session-model.joinSession
    • join-source.session-model.joinSessionRest
  • SessionStore.js.coffee now logs to buffer:
    • join-source.session-store.legacy-load
    • join-source.session-store.legacy-init
    • join-source.session-store.legacy-onJoinSession
  • SessionStoreModern.es6 now logs to buffer:
    • join-source.session-store.modern-load
    • join-source.session-store.modern-init
  • CallbackStore.js.coffee now records generic callback joins in buffer (join-source.generic-callback.join_session) and removed console spam.
  • session_utils.js join-source trace now writes to buffer (join-source.session-utils.joinSession) while downgrading console severity.
  • Added a hard gate in SessionStoreModern.es6 so the modern Reflux store only installs when window.__JK_SKIP_LEGACY_SESSION_STORE__ is true.
  • In legacy bundle loads, modern store now emits join-source.session-store.modern-skipped and does not register listeners; this prevents hidden second onJoinSession listeners from racing the legacy store.
  • Added targeted leave-source instrumentation to diagnose immediate post-join session departure:
  • SessionActions.leaveSession now emits leave-source.session-action (call + trigger wrappers, with stack) in react-components/actions/SessionActions.js.coffee.
  • SessionStore.js.coffee#onLeaveSession now emits leave-source.session-store.legacy-onLeaveSession.
  • SessionStoreModern.es6#onLeaveSession now emits leave-source.session-store.modern-onLeaveSession.
  • This is intended to identify the exact caller path behind immediate DELETE /api/participants/:client_id after successful join.