@q9labsai/chalk-client 4.1.15 → 4.1.17

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 (149) hide show
  1. package/README.md +100 -5
  2. package/dist/access/grant.d.ts.map +1 -1
  3. package/dist/access/manager.d.ts.map +1 -1
  4. package/dist/{chunk-PQNSA7ZN.js → chunk-2DZUHV6I.js} +782 -122
  5. package/dist/{chunk-6ONSFCIY.js → chunk-A7BVZEFM.js} +7 -3
  6. package/dist/{chunk-LQSXEWLL.js → chunk-AUQTQOLR.js} +2685 -511
  7. package/dist/{chunk-PWUU6T3R.js → chunk-CZUJNAUI.js} +1196 -874
  8. package/dist/{chunk-NKOKY2AE.js → chunk-EWTITNJ3.js} +15 -101
  9. package/dist/chunk-HW5TTZL2.js +99 -0
  10. package/dist/chunk-RCDXIDCN.js +33 -0
  11. package/dist/{chunk-LSDDADCN.js → chunk-ZRMGKMX4.js} +1728 -517
  12. package/dist/client.d.ts +1452 -465
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/connection/dependencies.d.ts +11 -0
  15. package/dist/connection/dependencies.d.ts.map +1 -1
  16. package/dist/connection/diagnostics.d.ts +1 -1
  17. package/dist/connection/diagnostics.d.ts.map +1 -1
  18. package/dist/connection/index.d.ts +3 -0
  19. package/dist/connection/index.d.ts.map +1 -1
  20. package/dist/connection/lifecycle.d.ts +4 -1
  21. package/dist/connection/lifecycle.d.ts.map +1 -1
  22. package/dist/connection/media-devices.d.ts.map +1 -1
  23. package/dist/connection/production.d.ts +4 -1
  24. package/dist/connection/production.d.ts.map +1 -1
  25. package/dist/connection/types.d.ts +2 -2
  26. package/dist/connection/types.d.ts.map +1 -1
  27. package/dist/effect.js +355 -130
  28. package/dist/feedback/collector.d.ts +14 -0
  29. package/dist/feedback/collector.d.ts.map +1 -0
  30. package/dist/feedback/context.d.ts +38 -0
  31. package/dist/feedback/context.d.ts.map +1 -0
  32. package/dist/feedback/controller.d.ts +4 -0
  33. package/dist/feedback/controller.d.ts.map +1 -0
  34. package/dist/feedback/index.d.ts +7 -0
  35. package/dist/feedback/index.d.ts.map +1 -0
  36. package/dist/feedback/transport.d.ts +29 -0
  37. package/dist/feedback/transport.d.ts.map +1 -0
  38. package/dist/feedback/types.d.ts +202 -0
  39. package/dist/feedback/types.d.ts.map +1 -0
  40. package/dist/feedback/validation.d.ts +30 -0
  41. package/dist/feedback/validation.d.ts.map +1 -0
  42. package/dist/foreground-deadline.d.ts +9 -0
  43. package/dist/foreground-deadline.d.ts.map +1 -0
  44. package/dist/generated/http-api.d.ts +1438 -461
  45. package/dist/generated/http-api.d.ts.map +1 -1
  46. package/dist/generated/schemas.d.ts +2667 -842
  47. package/dist/generated/schemas.d.ts.map +1 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +136 -21
  51. package/dist/media/camera-uplink.d.ts +13 -0
  52. package/dist/media/camera-uplink.d.ts.map +1 -0
  53. package/dist/media/capture-error.d.ts +27 -0
  54. package/dist/media/capture-error.d.ts.map +1 -0
  55. package/dist/media/client.d.ts +4 -0
  56. package/dist/media/client.d.ts.map +1 -1
  57. package/dist/media/index.d.ts +1 -0
  58. package/dist/media/index.d.ts.map +1 -1
  59. package/dist/media/index.js +5 -1
  60. package/dist/media/plane.d.ts +4 -0
  61. package/dist/media/plane.d.ts.map +1 -1
  62. package/dist/media/remote-track-response.d.ts +6 -0
  63. package/dist/media/remote-track-response.d.ts.map +1 -0
  64. package/dist/media/rtk.d.ts.map +1 -1
  65. package/dist/media/transport.d.ts.map +1 -1
  66. package/dist/media/types.d.ts +20 -2
  67. package/dist/media/types.d.ts.map +1 -1
  68. package/dist/public-invites/client.d.ts.map +1 -1
  69. package/dist/public-invites/index.js +4 -3
  70. package/dist/public-invites/types.d.ts +4 -2
  71. package/dist/public-invites/types.d.ts.map +1 -1
  72. package/dist/server/access-refresh.d.cts +13 -0
  73. package/dist/server/access-refresh.d.ts +14 -0
  74. package/dist/server/access-refresh.d.ts.map +1 -0
  75. package/dist/server/client.d.ts.map +1 -1
  76. package/dist/server/index.cjs +189 -23
  77. package/dist/server/index.d.cts +2 -1
  78. package/dist/server/index.d.ts +2 -1
  79. package/dist/server/index.d.ts.map +1 -1
  80. package/dist/server/index.js +187 -22
  81. package/dist/server/types.d.cts +76 -1
  82. package/dist/server/types.d.ts +76 -1
  83. package/dist/server/types.d.ts.map +1 -1
  84. package/dist/space-client/chat-controller.d.ts +3 -0
  85. package/dist/space-client/chat-controller.d.ts.map +1 -1
  86. package/dist/space-client/controller-effects.d.ts +6 -0
  87. package/dist/space-client/controller-effects.d.ts.map +1 -1
  88. package/dist/space-client/core.d.ts +12 -1
  89. package/dist/space-client/core.d.ts.map +1 -1
  90. package/dist/space-client/core.test.helpers.d.ts +2 -1
  91. package/dist/space-client/core.test.helpers.d.ts.map +1 -1
  92. package/dist/space-client/effect.d.ts +5 -0
  93. package/dist/space-client/effect.d.ts.map +1 -1
  94. package/dist/space-client/episode-diagnostic-runtime.d.ts +3 -0
  95. package/dist/space-client/episode-diagnostic-runtime.d.ts.map +1 -1
  96. package/dist/space-client/errors.d.ts.map +1 -1
  97. package/dist/space-client/index.d.ts +4 -1
  98. package/dist/space-client/index.d.ts.map +1 -1
  99. package/dist/space-client/media-controller.d.ts.map +1 -1
  100. package/dist/space-client/offline-actions-storage.d.ts +66 -0
  101. package/dist/space-client/offline-actions-storage.d.ts.map +1 -0
  102. package/dist/space-client/offline-actions.d.ts +36 -0
  103. package/dist/space-client/offline-actions.d.ts.map +1 -0
  104. package/dist/space-client/participants-controller.d.ts +2 -1
  105. package/dist/space-client/participants-controller.d.ts.map +1 -1
  106. package/dist/space-client/recording-controller.d.ts +20 -0
  107. package/dist/space-client/recording-controller.d.ts.map +1 -0
  108. package/dist/space-client/reload-rejoin.d.ts +24 -0
  109. package/dist/space-client/reload-rejoin.d.ts.map +1 -0
  110. package/dist/space-client/space-client.d.ts.map +1 -1
  111. package/dist/space-client/store.d.ts +3 -1
  112. package/dist/space-client/store.d.ts.map +1 -1
  113. package/dist/space-client/terminal.test.helpers.d.ts +115 -0
  114. package/dist/space-client/terminal.test.helpers.d.ts.map +1 -0
  115. package/dist/space-client/types.d.ts +42 -4
  116. package/dist/space-client/types.d.ts.map +1 -1
  117. package/dist/sync/index.js +2 -1
  118. package/dist/sync/v1-client.d.ts +2 -5
  119. package/dist/sync/v1-client.d.ts.map +1 -1
  120. package/dist/sync/v1-collaboration-state.d.ts +65 -0
  121. package/dist/sync/v1-collaboration-state.d.ts.map +1 -0
  122. package/dist/sync/v1-command-scheduler.d.ts +54 -0
  123. package/dist/sync/v1-command-scheduler.d.ts.map +1 -0
  124. package/dist/sync/v1-deferred.d.ts +8 -0
  125. package/dist/sync/v1-deferred.d.ts.map +1 -0
  126. package/dist/sync/v1-error.d.ts +5 -0
  127. package/dist/sync/v1-error.d.ts.map +1 -0
  128. package/dist/sync/v1-frame-signature.d.ts +2 -0
  129. package/dist/sync/v1-frame-signature.d.ts.map +1 -0
  130. package/dist/sync/v1-live-target-coordinator.d.ts +32 -0
  131. package/dist/sync/v1-live-target-coordinator.d.ts.map +1 -0
  132. package/dist/sync/v1-types.d.ts +3 -0
  133. package/dist/sync/v1-types.d.ts.map +1 -1
  134. package/dist/telemetry/index.js +9 -7
  135. package/dist/telemetry/reconnect.d.ts +7 -0
  136. package/dist/telemetry/reconnect.d.ts.map +1 -0
  137. package/dist/telemetry/trace.d.ts +1 -1
  138. package/dist/telemetry/trace.d.ts.map +1 -1
  139. package/dist/webhooks/generated/event-v1.d.cts +10 -2
  140. package/dist/webhooks/generated/event-v1.d.ts +10 -2
  141. package/dist/webhooks/generated/event-v1.d.ts.map +1 -1
  142. package/dist/webhooks/generated/fixtures-v1.d.cts +2 -2
  143. package/dist/webhooks/generated/fixtures-v1.d.ts +2 -2
  144. package/dist/webhooks/index.cjs +10 -2
  145. package/dist/webhooks/index.js +10 -2
  146. package/dist/webhooks/test.cjs +2 -2
  147. package/dist/webhooks/test.js +2 -2
  148. package/dist/whiteboard/v1-client.d.ts.map +1 -1
  149. package/package.json +1 -1
@@ -1,26 +1,38 @@
1
+ import {
2
+ AccessGrantError,
3
+ requireParsedAccessGrant,
4
+ validEpisodeDiagnosticCredential
5
+ } from "./chunk-ZRMGKMX4.js";
1
6
  import {
2
7
  CloudflareRTKClient,
3
8
  CloudflareSFUClient,
4
- createCloudflareSFUHTTPTransport
5
- } from "./chunk-PQNSA7ZN.js";
9
+ createCloudflareSFUHTTPTransport,
10
+ describeMediaCaptureError
11
+ } from "./chunk-2DZUHV6I.js";
6
12
  import {
13
+ ChatAttachmentMimeTypeSchema,
14
+ V1SyncError,
7
15
  createBrowserSyncLifecycle,
8
16
  createBrowserWebSocketFactory,
9
17
  createV1SyncClient
10
- } from "./chunk-PWUU6T3R.js";
18
+ } from "./chunk-CZUJNAUI.js";
11
19
  import {
12
- AccessGrantError,
13
- requireParsedAccessGrant,
14
- validEpisodeDiagnosticCredential
15
- } from "./chunk-LSDDADCN.js";
20
+ recordInitialConnection,
21
+ recordReconnect
22
+ } from "./chunk-RCDXIDCN.js";
23
+ import {
24
+ normalizeTelemetryAttributes
25
+ } from "./chunk-EWTITNJ3.js";
16
26
  import {
17
27
  createTraceContext,
28
+ journeyHeaders,
18
29
  parseTraceparent
19
- } from "./chunk-NKOKY2AE.js";
30
+ } from "./chunk-HW5TTZL2.js";
20
31
 
21
32
  // src/access/manager.ts
22
33
  import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
23
34
  var DEFAULT_REFRESH_WINDOW_MS = 6e4;
35
+ var ACCESS_PROVIDER_TIMEOUT_MS = 7e3;
24
36
  var ConnectionAccessFailure = class extends Data.TaggedError("ConnectionAccessFailure") {
25
37
  };
26
38
  var ConnectionAccessService = class extends Context.Service()("@chalk/client/ConnectionAccess") {
@@ -34,7 +46,7 @@ var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WIN
34
46
  Effect.tap((result) => Effect.sync(() => notifyAutomaticMediaReplacement(automaticMediaReplacementListeners, result))),
35
47
  Effect.map((result) => result.access)
36
48
  );
37
- const initialize = (reason = "join") => fetch(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (failure2) => failure2.code === "access.invalid" && SynchronizedRef.getUnsafe(state).access === null ? fetch(reason, false, true) : Effect.fail(failure2)));
49
+ const initialize = (reason = "join") => fetch(reason, false, false).pipe(Effect.catchTag("ConnectionAccessFailure", (failure3) => failure3.code === "access.invalid" && SynchronizedRef.getUnsafe(state).access === null ? fetch(reason, false, true) : Effect.fail(failure3)));
38
50
  const millisecondsUntilRefresh = Effect.gen(function* () {
39
51
  const access = yield* SynchronizedRef.get(state);
40
52
  if (!access.access) return null;
@@ -70,7 +82,10 @@ function fetchAccessGrant(provider, input) {
70
82
  }
71
83
  const mediaExpired = currentAccess !== null && expiresAt(currentAccess.media.expiresAt) <= now;
72
84
  const shouldReplaceMediaConnection = input.replaceMediaConnection || mediaExpired;
73
- const next = yield* provider(connectionAccessRequest(input, shouldReplaceMediaConnection));
85
+ const next = yield* provider(connectionAccessRequest(input, shouldReplaceMediaConnection)).pipe(
86
+ Effect.timeout(ACCESS_PROVIDER_TIMEOUT_MS),
87
+ Effect.mapError((cause) => cause instanceof ConnectionAccessFailure ? cause : new ConnectionAccessFailure({ code: "access.unavailable", cause }))
88
+ );
74
89
  yield* Effect.try({
75
90
  try: () => validateFetchedAccess(currentAccess, next, now, shouldReplaceMediaConnection),
76
91
  catch: (cause) => new ConnectionAccessFailure({ code: "access.invalid", cause })
@@ -139,6 +154,20 @@ var ConnectionPlatformService = class extends Context2.Service()("@chalk/client/
139
154
  var makeConnectionPlatformLayer = (dependencies) => Layer2.succeed(ConnectionPlatformService, dependencies);
140
155
  var makeFakeConnectionPlatformLayer = makeConnectionPlatformLayer;
141
156
 
157
+ // src/whiteboard/types.ts
158
+ var ChalkWhiteboardV1Error = class extends Error {
159
+ operation;
160
+ code;
161
+ recoverable;
162
+ constructor(failure3, options) {
163
+ super(failure3.message, options);
164
+ this.name = "ChalkWhiteboardV1Error";
165
+ this.operation = failure3.operation;
166
+ this.code = failure3.code;
167
+ this.recoverable = failure3.recoverable;
168
+ }
169
+ };
170
+
142
171
  // src/collaboration/types.ts
143
172
  var CHALK_CHAT_ATTACHMENT_LIMITS = {
144
173
  maximumPerMessage: 5,
@@ -168,12 +197,12 @@ var ChalkChatFileError = class extends Error {
168
197
  operation;
169
198
  code;
170
199
  recoverable;
171
- constructor(failure2, options) {
172
- super(failure2.message, options);
200
+ constructor(failure3, options) {
201
+ super(failure3.message, options);
173
202
  this.name = "ChalkChatFileError";
174
- this.operation = failure2.operation;
175
- this.code = failure2.code;
176
- this.recoverable = failure2.recoverable;
203
+ this.operation = failure3.operation;
204
+ this.code = failure3.code;
205
+ this.recoverable = failure3.recoverable;
177
206
  }
178
207
  };
179
208
 
@@ -286,20 +315,6 @@ function isAllowedMimeType(value) {
286
315
  return typeof value === "string" && CHALK_CHAT_ATTACHMENT_MIME_TYPES.includes(value);
287
316
  }
288
317
 
289
- // src/whiteboard/types.ts
290
- var ChalkWhiteboardV1Error = class extends Error {
291
- operation;
292
- code;
293
- recoverable;
294
- constructor(failure2, options) {
295
- super(failure2.message, options);
296
- this.name = "ChalkWhiteboardV1Error";
297
- this.operation = failure2.operation;
298
- this.code = failure2.code;
299
- this.recoverable = failure2.recoverable;
300
- }
301
- };
302
-
303
318
  // src/whiteboard/file-http-transport.ts
304
319
  function createChalkWhiteboardV1FileHttpTransport(options) {
305
320
  const fetchImplementation = options.fetch ?? globalThis.fetch;
@@ -1028,7 +1043,7 @@ var ChalkWhiteboardV1Client = class {
1028
1043
  return () => this.#summaryListeners.delete(listener);
1029
1044
  }
1030
1045
  submitUpdate(input) {
1031
- this.#assertLive("submit_update");
1046
+ if (!this.#started || !this.#sceneId) this.#assertLive("submit_update");
1032
1047
  if (!this.#canDraw || !this.#capabilities.includes("drawWhiteboard")) {
1033
1048
  throw error(failure("submit_update", "permission_denied", false, "Whiteboard draw permission is required."));
1034
1049
  }
@@ -1042,7 +1057,7 @@ var ChalkWhiteboardV1Client = class {
1042
1057
  });
1043
1058
  }
1044
1059
  sendCursor(input) {
1045
- if (this.#phase !== "live" || !this.#canDraw) return;
1060
+ if (this.#phase !== "live" || this.#summaryStatus !== "ready" || this.#snapshots.size > 0 || !this.#canDraw) return;
1046
1061
  const now = this.#now();
1047
1062
  if (now - this.#lastCursorAt < 1e3 / WhiteboardV1ProtocolLimits.cursorRatePerSecond) return;
1048
1063
  const frame = { type: "cursor", x: input.x, y: input.y };
@@ -1289,6 +1304,8 @@ var ChalkWhiteboardV1Client = class {
1289
1304
  void this.#requestSnapshot().catch(() => void 0);
1290
1305
  }
1291
1306
  #requestSnapshot() {
1307
+ const active2 = this.#snapshots.values().next().value;
1308
+ if (active2) return active2.promise;
1292
1309
  const requestId = this.#nextId();
1293
1310
  const frame = { type: "request_snapshot", request_id: requestId };
1294
1311
  encodeWhiteboardV1ClientFrame(frame);
@@ -1296,7 +1313,7 @@ var ChalkWhiteboardV1Client = class {
1296
1313
  const promise = new Promise((resolve, reject) => {
1297
1314
  deferred = { resolve, reject, settled: false };
1298
1315
  });
1299
- this.#snapshots.set(requestId, { requestId, deferred, pages: /* @__PURE__ */ new Map() });
1316
+ this.#snapshots.set(requestId, { requestId, deferred, promise, pages: /* @__PURE__ */ new Map() });
1300
1317
  this.#send(frame);
1301
1318
  return promise;
1302
1319
  }
@@ -1328,6 +1345,7 @@ var ChalkWhiteboardV1Client = class {
1328
1345
  this.#sceneId = frame.scene_id;
1329
1346
  this.#revision = frame.revision;
1330
1347
  this.#publishSummary("ready", null);
1348
+ this.#flushQueuedOperations();
1331
1349
  this.#emit({
1332
1350
  type: "snapshot",
1333
1351
  sceneId: frame.scene_id,
@@ -1375,7 +1393,10 @@ var ChalkWhiteboardV1Client = class {
1375
1393
  deferred = { resolve, reject, settled: false };
1376
1394
  });
1377
1395
  this.#operations.set(frame.operation_id, { pending, deferred, retries: 0 });
1378
- this.#sendOperation(frame);
1396
+ if (this.#phase === "live" && this.#snapshots.size === 0) {
1397
+ if (this.#waitingForOperations) this.#awaitingOperationIds.add(frame.operation_id);
1398
+ this.#sendOperation(frame);
1399
+ }
1379
1400
  return promise;
1380
1401
  }
1381
1402
  #commit(frame) {
@@ -1423,6 +1444,13 @@ var ChalkWhiteboardV1Client = class {
1423
1444
  rejectDeferred(this.#initialSnapshot, snapshotError);
1424
1445
  this.#initialSnapshot = null;
1425
1446
  }
1447
+ if (frame.recoverable) this.#flushQueuedOperations();
1448
+ }
1449
+ #flushQueuedOperations() {
1450
+ if (this.#phase !== "live" || this.#snapshots.size > 0) return;
1451
+ for (const entry of this.#operations.values()) {
1452
+ if (!this.#awaitingOperationIds.has(entry.pending.operationId)) this.#sendOperation(entry.pending.frame);
1453
+ }
1426
1454
  }
1427
1455
  #scheduleOperationRetry(operationId, entry) {
1428
1456
  if (this.#operationRetryTimers.has(operationId)) return;
@@ -1462,8 +1490,6 @@ var ChalkWhiteboardV1Client = class {
1462
1490
  this.#phase = "connecting";
1463
1491
  this.#waitingForOperations = false;
1464
1492
  this.#participantId = null;
1465
- this.#capabilities = [];
1466
- this.#canDraw = false;
1467
1493
  this.#rejectSnapshots(failure("request_snapshot", "unavailable", true, "Whiteboard connection interrupted."));
1468
1494
  this.#publishSummary("recovering", failure("start_scene_subscription", "unavailable", true, "Whiteboard connection interrupted."));
1469
1495
  }
@@ -1701,15 +1727,29 @@ function createChalkWhiteboardV1Client(options) {
1701
1727
  });
1702
1728
  }
1703
1729
 
1730
+ // src/connection/types.ts
1731
+ var ConnectionError = class extends Error {
1732
+ code;
1733
+ action;
1734
+ recoverable;
1735
+ constructor(failure3, options) {
1736
+ super(failure3.message, options);
1737
+ this.name = "ConnectionError";
1738
+ this.code = failure3.code;
1739
+ this.action = failure3.action;
1740
+ this.recoverable = failure3.recoverable;
1741
+ }
1742
+ };
1743
+
1704
1744
  // src/connection/media-devices.ts
1705
1745
  function createBrowserMediaDevices(mediaDevices = globalThis.navigator?.mediaDevices) {
1706
1746
  return {
1707
1747
  async getUserMedia(constraints) {
1708
- if (!mediaDevices?.getUserMedia) throw new TypeError("Browser media capture is unavailable");
1748
+ if (!mediaDevices?.getUserMedia || globalThis.isSecureContext === false) throw new ConnectionError({ code: "unsupported_environment", action: null, recoverable: false, message: "Browser media capture is unavailable. Use a supported browser over HTTPS." });
1709
1749
  return mediaDevices.getUserMedia(constraints);
1710
1750
  },
1711
1751
  async getDisplayMedia(constraints) {
1712
- if (!mediaDevices?.getDisplayMedia) throw new TypeError("Browser display capture is unavailable");
1752
+ if (!mediaDevices?.getDisplayMedia) throw new DOMException("Browser display capture is unavailable", "NotSupportedError");
1713
1753
  return mediaDevices.getDisplayMedia(constraints);
1714
1754
  },
1715
1755
  async enumerateDevices() {
@@ -1802,12 +1842,14 @@ function createMediaClient(apiBaseURL, input) {
1802
1842
  } : void 0,
1803
1843
  transport: createCloudflareSFUHTTPTransport({
1804
1844
  apiBaseURL,
1845
+ telemetry: input.telemetry,
1805
1846
  credential: input.credential,
1806
1847
  tenantId: subject.tenantId,
1807
1848
  spaceId: subject.spaceId,
1808
1849
  episodeId: subject.episodeId,
1809
1850
  participantId: subject.participantId
1810
1851
  }),
1852
+ recordReconnect: input.recordReconnect,
1811
1853
  onError: input.onFailure,
1812
1854
  ...input.recordRtcSummary ? { onRtcSummary: input.recordRtcSummary } : {},
1813
1855
  onScreenEnded: input.onScreenEnded
@@ -1832,8 +1874,12 @@ function createSFUConnectionMediaClient(client) {
1832
1874
  function bindConnectionMediaClient(client, restart) {
1833
1875
  return {
1834
1876
  setLocalPublicationTarget: client.setLocalPublicationTarget.bind(client),
1877
+ ...client.setLocalSourceIntent ? { setLocalSourceIntent: client.setLocalSourceIntent.bind(client) } : {},
1878
+ ...client.closeForcedLocalPublication ? { closeForcedLocalPublication: client.closeForcedLocalPublication.bind(client) } : {},
1835
1879
  observeLocalPublications: client.observeLocalPublications.bind(client),
1836
1880
  observeRemotePublications: client.observeRemotePublications.bind(client),
1881
+ ...client.remotePublicationsChanged ? { remotePublicationsChanged: client.remotePublicationsChanged.bind(client) } : {},
1882
+ ...client.remotePublicationResumed ? { remotePublicationResumed: client.remotePublicationResumed.bind(client) } : {},
1837
1883
  start: client.start.bind(client),
1838
1884
  stop: client.stop.bind(client),
1839
1885
  restart,
@@ -1849,6 +1895,7 @@ function createSyncClient(syncURL, input) {
1849
1895
  token: input.token,
1850
1896
  mediaPlane: input.media,
1851
1897
  telemetry: input.telemetry,
1898
+ recordReconnect: input.recordReconnect,
1852
1899
  persistenceScope: `${input.access.subject.tenantId}:${input.access.subject.episodeId}:${input.access.subject.participantId}`
1853
1900
  });
1854
1901
  }
@@ -1880,6 +1927,26 @@ function whiteboardURL(syncURL) {
1880
1927
  return url.toString();
1881
1928
  }
1882
1929
 
1930
+ // src/foreground-deadline.ts
1931
+ function scheduleForegroundDeadline(clock, milliseconds, expired) {
1932
+ let timer;
1933
+ let cancelled = false;
1934
+ const arm = () => {
1935
+ const started = clock.now();
1936
+ timer = clock.setTimeout(() => {
1937
+ if (cancelled) return;
1938
+ const elapsed = clock.now() - started;
1939
+ if (typeof document !== "undefined" && document.visibilityState === "hidden" || elapsed > milliseconds + 2e3 || elapsed < 0) arm();
1940
+ else expired();
1941
+ }, milliseconds);
1942
+ };
1943
+ arm();
1944
+ return () => {
1945
+ cancelled = true;
1946
+ clock.clearTimeout(timer);
1947
+ };
1948
+ }
1949
+
1883
1950
  // src/connection/lifecycle.ts
1884
1951
  import { Clock as Clock2, Context as Context3, Data as Data2, Deferred, Duration, Effect as Effect2, Exit, Fiber, Layer as Layer3, Queue, Scope, SubscriptionRef } from "effect";
1885
1952
 
@@ -1936,26 +2003,15 @@ var ConnectionDiagnostics = class {
1936
2003
  }
1937
2004
  };
1938
2005
 
1939
- // src/connection/types.ts
1940
- var ConnectionError = class extends Error {
1941
- code;
1942
- action;
1943
- recoverable;
1944
- constructor(failure2, options) {
1945
- super(failure2.message, options);
1946
- this.name = "ConnectionError";
1947
- this.code = failure2.code;
1948
- this.action = failure2.action;
1949
- this.recoverable = failure2.recoverable;
1950
- }
1951
- };
1952
-
1953
2006
  // src/connection/lifecycle.ts
2007
+ var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
1954
2008
  var START_TIMEOUT_MS = 1e4;
1955
2009
  var LEAVE_TIMEOUT_MS = 5e3;
1956
2010
  var RECOVERY_BUDGET_MS = 1e4;
1957
2011
  var MAX_RECOVERY_ATTEMPTS = 3;
1958
2012
  var REFRESH_RETRY_MS = 5e3;
2013
+ var RECONNECT_GRACE_MS = 250;
2014
+ var BACKGROUND_RETRY_MAX_MS = 6e4;
1959
2015
  var ConnectionLifecycleFailure = class extends Data2.TaggedError("ConnectionLifecycleFailure") {
1960
2016
  };
1961
2017
  var ConnectionLifecycleService = class extends Context3.Service()("@chalk/client/ConnectionLifecycle") {
@@ -1965,8 +2021,8 @@ var makeConnectionLifecycleLayer = (options) => {
1965
2021
  const platform = { ...defaults, ...options.dependencies };
1966
2022
  const access = makeConnectionAccessLayer(
1967
2023
  (request) => Effect2.tryPromise({
1968
- try: () => Promise.resolve(options.access(request)),
1969
- catch: (cause) => new ConnectionAccessFailure({ code: "access.unavailable", cause })
2024
+ try: (signal) => Promise.resolve(options.access(request ? { ...request, signal } : void 0)),
2025
+ catch: (cause) => cause instanceof ConnectionAccessFailure ? cause : new ConnectionAccessFailure({ code: accessRejected(cause) ? "access.invalid" : "access.unavailable", cause })
1970
2026
  }),
1971
2027
  options.accessRefreshWindowMs
1972
2028
  );
@@ -2001,32 +2057,65 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2001
2057
  let joinDeferred = null;
2002
2058
  let activeJoin = null;
2003
2059
  let fallbackIdentifier = 0;
2060
+ let emittedPorts = null;
2061
+ let foregroundRecoveryDeadline = 0;
2004
2062
  const model = {
2005
2063
  state: "idle",
2006
2064
  sync: null,
2007
2065
  media: null,
2008
2066
  syncSnapshot: null,
2067
+ syncNoticeUnresponsive: false,
2009
2068
  mediaSnapshot: null,
2010
2069
  failure: null,
2011
2070
  initialMedia: (intent) => Effect2.tryPromise({
2012
2071
  try: () => intent.microphone || intent.camera ? platform.mediaDevices.getUserMedia({ audio: intent.microphone, video: intent.camera }) : Promise.resolve(streamFromTracks([])),
2013
2072
  catch: (cause) => cause
2014
2073
  }),
2074
+ initialMediaIntent: null,
2015
2075
  intent: { microphone: options.initialMicrophoneEnabled ?? true, camera: options.initialCameraEnabled ?? true },
2016
2076
  episodeEndConfirmed: false,
2017
2077
  activeScope: null,
2018
2078
  syncBindingCleanup: null,
2019
2079
  mediaBindingCleanup: null,
2020
2080
  refreshFiber: null,
2081
+ refreshFailures: 0,
2082
+ refreshInFlight: false,
2021
2083
  recoveryQueued: null,
2022
2084
  joinCancelled: false,
2023
2085
  closed: false,
2024
2086
  epoch: 0
2025
2087
  };
2026
- const foreign6 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => lifecycleFailure(code, true, message, cause) });
2088
+ const mediaAttempt = { expiresAt: null };
2089
+ const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => lifecycleFailure(code, true, message, cause) });
2090
+ const withinForegroundBudget = (effect, milliseconds, failure3) => Effect2.raceFirst(
2091
+ effect,
2092
+ Effect2.callback((resume) => {
2093
+ const cancel = scheduleForegroundDeadline(platform.clock, milliseconds, () => resume(Effect2.fail(failure3)));
2094
+ return Effect2.sync(cancel);
2095
+ })
2096
+ );
2027
2097
  const toPromise = (effect) => Effect2.runPromiseWith(context)(effect);
2028
- const publish = () => Effect2.sync(() => snapshotFor(model, access.currentUnsafe())).pipe(
2029
- Effect2.tap((snapshot) => SubscriptionRef.set(snapshotRef, snapshot)),
2098
+ const publish = () => Effect2.sync(() => ({ previous: SubscriptionRef.getUnsafe(snapshotRef), snapshot: snapshotFor(model, access.currentUnsafe()) })).pipe(
2099
+ Effect2.tap(({ snapshot }) => SubscriptionRef.set(snapshotRef, snapshot)),
2100
+ Effect2.tap(
2101
+ ({ previous, snapshot }) => Effect2.sync(() => {
2102
+ const ports = portsFor(model);
2103
+ if (![
2104
+ ports?.sync === emittedPorts?.sync,
2105
+ ports?.media === emittedPorts?.media,
2106
+ ["healthy", "unresponsive"].includes(previous.connection.sync) === ["healthy", "unresponsive"].includes(snapshot.connection.sync),
2107
+ previous.connection.media === "healthy" === (snapshot.connection.media === "healthy")
2108
+ ].every(Boolean)) {
2109
+ emittedPorts = ports;
2110
+ for (const listener of portListeners) {
2111
+ try {
2112
+ listener(ports);
2113
+ } catch {
2114
+ }
2115
+ }
2116
+ }
2117
+ })
2118
+ ),
2030
2119
  Effect2.asVoid,
2031
2120
  Effect2.tap(
2032
2121
  () => Effect2.sync(() => {
@@ -2039,15 +2128,6 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2039
2128
  })
2040
2129
  )
2041
2130
  );
2042
- const emitPorts = () => Effect2.sync(() => {
2043
- const ports = portsFor(model);
2044
- for (const listener of portListeners) {
2045
- try {
2046
- listener(ports);
2047
- } catch {
2048
- }
2049
- }
2050
- });
2051
2131
  const transition = (state) => Effect2.gen(function* () {
2052
2132
  if (model.state === state) return;
2053
2133
  model.state = state;
@@ -2077,36 +2157,68 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2077
2157
  model.refreshFiber = yield* Effect2.forkIn(Effect2.sleep(Math.max(0, milliseconds)).pipe(Effect2.andThen(enqueue(refreshAccess())), Effect2.ignore), scope);
2078
2158
  });
2079
2159
  const refreshAccess = () => Effect2.gen(function* () {
2080
- if (!active(model)) return;
2160
+ const scope = model.activeScope;
2161
+ if (!active(model) || !scope || model.refreshInFlight) return;
2081
2162
  const epoch = model.epoch;
2082
- const result = yield* Effect2.exit(access.ensureFresh("scheduled_refresh"));
2083
- if (result._tag === "Failure") {
2084
- diagnostics.record({ event: "access_refresh_failed", state: model.state, epoch, code: "access_unavailable" });
2085
- yield* scheduleRefresh(REFRESH_RETRY_MS);
2086
- return;
2087
- }
2088
- diagnostics.record({ event: "access_refreshed", state: model.state, epoch });
2089
- yield* publish();
2090
- yield* scheduleRefresh();
2163
+ model.refreshInFlight = true;
2164
+ yield* Effect2.forkIn(
2165
+ access.ensureFresh("scheduled_refresh").pipe(
2166
+ Effect2.match({ onSuccess: () => null, onFailure: (cause) => cause }),
2167
+ Effect2.flatMap(
2168
+ (failure3) => enqueue(
2169
+ Effect2.gen(function* () {
2170
+ if (model.epoch !== epoch || !active(model)) return;
2171
+ model.refreshInFlight = false;
2172
+ if (failure3) {
2173
+ diagnostics.record({ event: "access_refresh_failed", state: model.state, epoch, code: failure3.code === "access.invalid" ? "invalid_access" : "access_unavailable" });
2174
+ if (failure3.code === "access.invalid") return yield* failForSnapshot(accessFailure(failure3));
2175
+ model.refreshFailures = Math.min(model.refreshFailures + 1, 5);
2176
+ yield* scheduleRefresh(Math.min(BACKGROUND_RETRY_MAX_MS, REFRESH_RETRY_MS * 2 ** (model.refreshFailures - 1)));
2177
+ return;
2178
+ }
2179
+ model.refreshFailures = 0;
2180
+ diagnostics.record({ event: "access_refreshed", state: model.state, epoch });
2181
+ yield* publish();
2182
+ yield* scheduleRefresh();
2183
+ })
2184
+ )
2185
+ ),
2186
+ Effect2.ignore
2187
+ ),
2188
+ scope
2189
+ );
2091
2190
  });
2092
2191
  const stopPorts = () => Effect2.gen(function* () {
2093
2192
  yield* stopRefresh();
2094
2193
  const scope = model.activeScope;
2095
2194
  model.activeScope = null;
2195
+ model.refreshFailures = 0;
2196
+ model.refreshInFlight = false;
2197
+ mediaAttempt.expiresAt = null;
2096
2198
  model.sync = null;
2097
2199
  model.media = null;
2098
2200
  model.syncSnapshot = null;
2201
+ model.syncNoticeUnresponsive = false;
2099
2202
  model.mediaSnapshot = null;
2100
- yield* emitPorts();
2203
+ model.recoveryQueued = null;
2204
+ yield* publish();
2101
2205
  if (scope) yield* Scope.close(scope, Exit.void);
2102
2206
  yield* access.clear;
2103
2207
  yield* publish();
2104
2208
  });
2105
2209
  const bindSync = (scope, sync) => Effect2.gen(function* () {
2106
2210
  model.syncBindingCleanup?.();
2211
+ model.syncNoticeUnresponsive = false;
2107
2212
  let latestSnapshot = null;
2108
2213
  let snapshotQueued = false;
2109
2214
  const unsubscribe = sync.subscribe((snapshot) => {
2215
+ if (sync === model.sync && snapshot.connection.phase === "live") {
2216
+ const notice = snapshot.connection.noticeUnresponsive === true;
2217
+ if (notice !== model.syncNoticeUnresponsive) {
2218
+ model.syncNoticeUnresponsive = notice;
2219
+ void Effect2.runForkWith(context)(publish());
2220
+ }
2221
+ }
2110
2222
  latestSnapshot = snapshot;
2111
2223
  if (snapshotQueued) return;
2112
2224
  snapshotQueued = true;
@@ -2152,22 +2264,19 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2152
2264
  yield* handleMediaSnapshot(media, media.getSnapshot());
2153
2265
  });
2154
2266
  const waitForSyncLive = (sync, timeoutMs) => Effect2.callback((resume) => {
2155
- if (sync.getSnapshot().connection.phase === "live") {
2156
- resume(Effect2.void);
2157
- return;
2158
- }
2159
- const unsubscribe = sync.subscribe((snapshot) => {
2160
- if (snapshot.connection.phase === "live") resume(Effect2.void);
2161
- else if (snapshot.connection.phase === "terminal" || snapshot.connection.phase === "stopped") resume(Effect2.fail(lifecycleFailure("sync_start_failed", true, "Sync stopped before becoming live")));
2162
- });
2267
+ const observe = (snapshot) => {
2268
+ if (episodeEnded(snapshot)) resume(Effect2.fail(lifecycleFailure("episode_ended", false, "The Episode has ended")));
2269
+ else if (snapshot.connection.phase === "live") resume(Effect2.void);
2270
+ else if (snapshot.connection.phase === "terminal") resume(Effect2.fail(syncTerminalFailure(snapshot)));
2271
+ else if (snapshot.connection.phase === "stopped") resume(Effect2.fail(lifecycleFailure("sync_start_failed", true, "Sync stopped before becoming live")));
2272
+ };
2273
+ const unsubscribe = sync.subscribe(observe);
2274
+ observe(sync.getSnapshot());
2163
2275
  return Effect2.sync(unsubscribe);
2164
- }).pipe(
2165
- Effect2.timeout(timeoutMs),
2166
- Effect2.catchTag("TimeoutError", () => Effect2.fail(lifecycleFailure("sync_start_failed", true, "Sync did not become live before the startup deadline")))
2167
- );
2276
+ }).pipe((effect) => withinForegroundBudget(effect, timeoutMs, lifecycleFailure("sync_start_failed", true, "Sync did not become live before the startup deadline")));
2168
2277
  const trace = (step, effect) => {
2169
2278
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
2170
- return effect.pipe(
2279
+ return traceRecovery(step, effect, recordInitialConnection).pipe(
2171
2280
  Effect2.tap(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" }))),
2172
2281
  Effect2.tapError(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "failed" })))
2173
2282
  );
@@ -2183,9 +2292,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2183
2292
  const scope = yield* Scope.make("sequential");
2184
2293
  model.activeScope = scope;
2185
2294
  let stream = null;
2295
+ let captureWarning = null;
2186
2296
  yield* Effect2.gen(function* () {
2187
2297
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2188
- stream = yield* trace("acquire_initial_media", model.initialMedia(model.intent).pipe(Effect2.mapError((cause) => captureFailure(cause))));
2298
+ const captured = yield* trace("acquire_initial_media", acquireInitialMedia(model.initialMedia, model.initialMediaIntent?.() ?? model.intent));
2299
+ stream = captured.stream;
2300
+ captureWarning = captured.failure;
2189
2301
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2190
2302
  const grant = yield* trace("access_initialize", access.initialize(reason));
2191
2303
  const media = yield* trace(
@@ -2194,7 +2306,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2194
2306
  try: () => platform.createMediaClient({
2195
2307
  access: grant,
2196
2308
  credential: () => toPromise(access.getMediaToken()),
2197
- replaceMediaConnection: () => toPromise(access.refresh("media_recovery", true)).then((replacement) => replacement.media),
2309
+ replaceMediaConnection: () => toPromise(access.refresh("media_recovery", true)).then((replacement) => {
2310
+ if (media === model.media) mediaAttempt.expiresAt = platform.clock.now() + MEDIA_RECOVERY_TIMEOUT_MS;
2311
+ return replacement.media;
2312
+ }),
2313
+ recordReconnect: options.recordReconnect,
2314
+ telemetry: options.telemetry,
2198
2315
  ...options.recordRtcSummary ? { recordRtcSummary: options.recordRtcSummary } : {},
2199
2316
  onFailure: () => enqueueBackground(handleMediaFailure()),
2200
2317
  onScreenEnded: () => enqueueBackground(notifyScreenEnded())
@@ -2206,7 +2323,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2206
2323
  const sync = yield* trace(
2207
2324
  "create_sync_client",
2208
2325
  Effect2.try({
2209
- try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry }),
2326
+ try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry, recordReconnect: options.recordReconnect }),
2210
2327
  catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
2211
2328
  })
2212
2329
  );
@@ -2216,9 +2333,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2216
2333
  [
2217
2334
  trace(
2218
2335
  "start_media",
2219
- foreign6(() => media.start(stream), "media_start_failed", "The media layer could not start")
2336
+ foreign7(() => media.start(stream), "media_start_failed", "The media layer could not start")
2220
2337
  ),
2221
- trace("start_sync", foreign6(() => sync.start(), "sync_start_failed", "The sync layer could not start").pipe(Effect2.andThen(trace("wait_for_sync_live", waitForSyncLive(sync, boundedInteger(options.syncStartupTimeoutMs, START_TIMEOUT_MS, 1, 6e4))))))
2338
+ trace("start_sync", foreign7(() => sync.start(), "sync_start_failed", "The sync layer could not start").pipe(Effect2.andThen(trace("wait_for_sync_live", waitForSyncLive(sync, boundedInteger(options.syncStartupTimeoutMs, START_TIMEOUT_MS, 1, 6e4))))))
2222
2339
  ],
2223
2340
  { concurrency: "unbounded", discard: true }
2224
2341
  );
@@ -2226,23 +2343,26 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2226
2343
  Effect2.matchEffect({
2227
2344
  onFailure: (cause) => Effect2.gen(function* () {
2228
2345
  stopStream(stream);
2229
- const failure2 = failureFromCause(cause, "sync_start_failed", "The Connection could not start");
2230
- model.failure = toFailure(failure2);
2346
+ const failure3 = failureFromCause(cause, "sync_start_failed", "The Connection could not start");
2347
+ model.failure = toFailure(failure3);
2231
2348
  if (model.joinCancelled) {
2232
2349
  span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "invalid_state" });
2233
2350
  return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2234
2351
  }
2352
+ span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure3.code });
2353
+ const retry = !retried && failure3.code === "invalid_access";
2354
+ if (!retry) yield* recordFailure(failure3);
2235
2355
  yield* stopPorts();
2236
- if (!retried && failure2.code === "invalid_access") return yield* performJoin("access_retry", true);
2356
+ if (retry) return yield* performJoin("access_retry", true);
2237
2357
  yield* transition("failed");
2238
- span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure2.code });
2239
- return yield* Effect2.fail(failure2);
2358
+ return yield* Effect2.fail(failure3);
2240
2359
  }),
2241
2360
  onSuccess: () => Effect2.gen(function* () {
2242
2361
  model.syncSnapshot = model.sync?.getSnapshot() ?? null;
2243
2362
  model.mediaSnapshot = model.media?.getSnapshot() ?? null;
2363
+ model.failure = captureWarning ? toFailure(captureWarning) : null;
2244
2364
  yield* transition("live");
2245
- yield* emitPorts();
2365
+ if (model.mediaSnapshot?.connection.phase !== "live") yield* recover("media");
2246
2366
  yield* scheduleRefresh();
2247
2367
  span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" });
2248
2368
  })
@@ -2262,43 +2382,118 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2262
2382
  const leaveIsConfirmed = () => Effect2.gen(function* leaveIsConfirmedEffect() {
2263
2383
  const sync = model.sync;
2264
2384
  if (!sync || model.episodeEndConfirmed || access.currentUnsafe() === null) return true;
2265
- const leave = foreign6(() => sync.leave()).pipe(Effect2.timeout(LEAVE_TIMEOUT_MS));
2385
+ const leave = foreign7(() => sync.leave()).pipe(Effect2.timeout(LEAVE_TIMEOUT_MS));
2266
2386
  return (yield* Effect2.exit(leave))._tag === "Success";
2267
2387
  });
2388
+ const publishFreshAccess = () => enqueue(
2389
+ Effect2.gen(function* () {
2390
+ if (!active(model)) return;
2391
+ diagnostics.record({ event: "access_refreshed", state: model.state, epoch: model.epoch });
2392
+ yield* publish();
2393
+ yield* scheduleRefresh();
2394
+ })
2395
+ );
2396
+ const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed")) : Effect2.void);
2268
2397
  const withFreshAccess = (operation) => Effect2.gen(function* () {
2398
+ yield* requireOpen;
2269
2399
  if (yield* access.requiresRefresh) {
2400
+ yield* requireOpen;
2270
2401
  yield* access.ensureFresh("scheduled_refresh").pipe(Effect2.mapError(accessFailure));
2271
- diagnostics.record({ event: "access_refreshed", state: model.state, epoch: model.epoch });
2272
- yield* scheduleRefresh();
2402
+ yield* publishFreshAccess();
2273
2403
  }
2274
- return yield* operation().pipe(
2404
+ const run = requireOpen.pipe(
2405
+ Effect2.andThen(Effect2.suspend(operation)),
2406
+ Effect2.flatMap((value) => requireOpen.pipe(Effect2.as(value)))
2407
+ );
2408
+ return yield* run.pipe(
2275
2409
  Effect2.matchEffect({
2276
2410
  onSuccess: Effect2.succeed,
2277
2411
  onFailure: (cause) => {
2278
2412
  if (!accessRejected(cause)) return Effect2.fail(cause);
2279
- return access.refreshAfterRejection().pipe(
2280
- Effect2.mapError(accessFailure),
2281
- Effect2.tap(
2282
- () => Effect2.sync(() => {
2283
- diagnostics.record({ event: "access_refreshed", state: model.state, epoch: model.epoch });
2284
- })
2285
- ),
2286
- Effect2.andThen(scheduleRefresh()),
2287
- Effect2.andThen(operation())
2413
+ return requireOpen.pipe(
2414
+ Effect2.andThen(access.refreshAfterRejection()),
2415
+ Effect2.mapError((failure3) => failure3 instanceof ConnectionAccessFailure ? accessFailure(failure3) : failure3),
2416
+ Effect2.andThen(publishFreshAccess()),
2417
+ Effect2.andThen(run)
2288
2418
  );
2289
2419
  }
2290
2420
  })
2291
2421
  );
2292
2422
  });
2293
- const recover = (kind) => Effect2.gen(function* recoverEffect() {
2294
- if (!active(model)) return;
2295
- if (model.recoveryQueued === kind) return;
2423
+ const traceRecovery = (step, operation, recordStep = recordReconnect) => Effect2.gen(function* () {
2424
+ const started = yield* Clock2.currentTimeMillis;
2425
+ recordStep(options.recordReconnect, step, { boundary: "start" });
2426
+ return yield* operation.pipe(
2427
+ Effect2.onExit(
2428
+ (exit) => Clock2.currentTimeMillis.pipe(
2429
+ Effect2.map((now) => {
2430
+ recordStep(options.recordReconnect, step, { boundary: "end", duration_ms: Math.max(0, now - started) }, exit._tag === "Success" ? "succeeded" : "failed");
2431
+ })
2432
+ )
2433
+ )
2434
+ );
2435
+ });
2436
+ const recover = (kind) => Effect2.gen(function* () {
2437
+ const scope = model.activeScope;
2438
+ if (!active(model) || !scope) return;
2439
+ if (model.recoveryQueued !== null) {
2440
+ model.recoveryQueued = kind;
2441
+ yield* publish();
2442
+ return;
2443
+ }
2296
2444
  model.recoveryQueued = kind;
2297
- yield* transition("reconnecting");
2298
- const plan = yield* recoveryPlan(kind);
2299
- const attempt = yield* attemptRecovery(plan);
2300
- if (attempt !== null) return yield* finishRecovery(attempt);
2301
- yield* failRecovery(plan);
2445
+ const phase = kind === "sync" ? model.sync?.getSnapshot().connection.phase : model.media?.getSnapshot().connection.phase;
2446
+ if (phase === "terminal" || phase === "failed") yield* transition("reconnecting");
2447
+ const epoch = model.epoch;
2448
+ yield* Effect2.forkIn(
2449
+ Effect2.gen(function* () {
2450
+ yield* Effect2.sleep(RECONNECT_GRACE_MS);
2451
+ const phase2 = kind === "sync" ? model.sync?.getSnapshot().connection.phase : model.media?.getSnapshot().connection.phase;
2452
+ if (phase2 === "live") {
2453
+ enqueueBackground(finishRecovery(0, epoch));
2454
+ return;
2455
+ }
2456
+ yield* enqueue(
2457
+ Effect2.gen(function* () {
2458
+ if (model.epoch === epoch && active(model)) yield* transition("reconnecting");
2459
+ })
2460
+ );
2461
+ let cycle = 0;
2462
+ const syncAttempt = { client: null, expiresAt: null, started: true };
2463
+ while (model.epoch === epoch && active(model)) {
2464
+ const plan = yield* recoveryPlan(model.recoveryQueued ?? kind);
2465
+ const outcome = yield* attemptRecovery(plan, mediaAttempt, syncAttempt).pipe(
2466
+ Effect2.match({
2467
+ onSuccess: (attempt) => ({ attempt, failure: null }),
2468
+ onFailure: (failure3) => ({ attempt: null, failure: failure3 })
2469
+ })
2470
+ );
2471
+ if (outcome.failure) {
2472
+ const failure3 = outcome.failure;
2473
+ enqueueBackground(Effect2.suspend(() => model.epoch === epoch && active(model) ? failForSnapshot(failure3) : Effect2.void));
2474
+ return;
2475
+ }
2476
+ if (outcome.attempt !== null) {
2477
+ enqueueBackground(finishRecovery(outcome.attempt, epoch));
2478
+ return;
2479
+ }
2480
+ yield* enqueue(
2481
+ Effect2.gen(function* () {
2482
+ if (model.epoch !== epoch || !active(model)) return;
2483
+ const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
2484
+ model.failure = toFailure(lifecycleFailure(code, true, "Connection interrupted. Reconnecting in the background."));
2485
+ diagnostics.record({ event: "recovery_exhausted", state: model.state, epoch, code });
2486
+ yield* publish();
2487
+ })
2488
+ );
2489
+ if (model.recoveryQueued !== plan.kind) continue;
2490
+ const delay = Math.min(BACKGROUND_RETRY_MAX_MS, 250 * 2 ** cycle);
2491
+ cycle = Math.min(cycle + 1, 8);
2492
+ yield* Effect2.race(Effect2.sleep(delay * (0.5 + Math.random() * 0.5)), waitForTransportChange(plan.kind));
2493
+ }
2494
+ }),
2495
+ scope
2496
+ );
2302
2497
  });
2303
2498
  const recoveryPlan = (kind) => Clock2.currentTimeMillis.pipe(
2304
2499
  Effect2.map((now) => ({
@@ -2308,75 +2503,216 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2308
2503
  delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : [100, 250, 500]
2309
2504
  }))
2310
2505
  );
2311
- const attemptRecovery = (plan) => Effect2.gen(function* attemptRecoveryEffect() {
2506
+ const attemptRecovery = (plan, mediaAttempt2, syncAttempt) => Effect2.gen(function* () {
2312
2507
  for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
2313
2508
  const remaining = plan.deadline - (yield* Clock2.currentTimeMillis);
2314
2509
  if (remaining <= 0) return null;
2510
+ recordReconnect(options.recordReconnect, "attempt", { recovery_kind: plan.kind, attempt });
2315
2511
  diagnostics.record({ event: "recovery_attempt", state: model.state, epoch: model.epoch, attempt });
2316
- const outcome = yield* Effect2.exit(recoveryOperation(plan.kind).pipe(Effect2.timeout(remaining)));
2317
- if (outcome._tag === "Success") return attempt;
2318
- if (attempt < plan.attempts) yield* Effect2.sleep(recoveryDelay(plan, attempt));
2512
+ const failure3 = yield* recoveryOperation(mediaAttempt2, syncAttempt).pipe(
2513
+ Effect2.timeout(remaining),
2514
+ Effect2.catchTag("TimeoutError", () => Effect2.fail(lifecycleFailure("sync_start_failed", true, "Connection recovery is still waiting for the transport"))),
2515
+ Effect2.match({ onSuccess: () => null, onFailure: (cause) => cause })
2516
+ );
2517
+ if (failure3 === null) return attempt;
2518
+ if (!failure3.recoverable) return yield* Effect2.fail(failure3);
2519
+ if (attempt < plan.attempts) {
2520
+ const remaining2 = Math.max(0, plan.deadline - (yield* Clock2.currentTimeMillis));
2521
+ yield* traceRecovery("lifecycle_backoff", Effect2.sleep(Math.min(recoveryDelay(plan, attempt), remaining2)));
2522
+ }
2319
2523
  }
2320
2524
  return null;
2321
2525
  });
2322
- const recoveryOperation = (kind) => kind === "media" ? recoverMedia() : recoverSync();
2323
- const recoveryDelay = (plan, attempt) => Math.max(0, plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0);
2324
- const finishRecovery = (attempt) => Effect2.gen(function* finishRecoveryEffect() {
2526
+ const recoveryOperation = (mediaAttempt2, syncAttempt) => Effect2.gen(function* () {
2527
+ const results = yield* Effect2.all(
2528
+ [recoverSync(syncAttempt), recoverMedia(mediaAttempt2)].map((effect) => effect.pipe(Effect2.matchEffect({ onSuccess: () => Effect2.succeed(null), onFailure: (failure4) => failure4.recoverable ? Effect2.succeed(failure4) : Effect2.fail(failure4) }))),
2529
+ { concurrency: "unbounded" }
2530
+ );
2531
+ const failure3 = results.find((failure4) => failure4 !== null);
2532
+ if (failure3) return yield* Effect2.fail(failure3);
2533
+ });
2534
+ const recoveryDelay = (plan, attempt) => {
2535
+ const delay = plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0;
2536
+ return Number.isFinite(delay) ? Math.max(0, delay) : 0;
2537
+ };
2538
+ const finishRecovery = (attempt, epoch) => Effect2.gen(function* () {
2539
+ if (model.epoch !== epoch || !active(model)) return;
2325
2540
  model.recoveryQueued = null;
2326
- diagnostics.record({ event: "recovery_succeeded", state: model.state, epoch: model.epoch, attempt });
2541
+ model.syncSnapshot = model.sync?.getSnapshot() ?? null;
2542
+ model.mediaSnapshot = model.media?.getSnapshot() ?? null;
2543
+ model.failure = null;
2544
+ if (model.syncSnapshot?.connection.phase !== "live") return yield* recover("sync");
2545
+ if (model.mediaSnapshot?.connection.phase !== "live") return yield* recover("media");
2546
+ mediaAttempt.expiresAt = null;
2547
+ diagnostics.record({ event: "recovery_succeeded", state: model.state, epoch, attempt });
2327
2548
  yield* transition("live");
2549
+ yield* publish();
2328
2550
  yield* scheduleRefresh();
2329
2551
  });
2330
- const failRecovery = (plan) => Effect2.gen(function* failRecoveryEffect() {
2331
- const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
2332
- model.recoveryQueued = null;
2333
- model.failure = toFailure(lifecycleFailure(code, false, `${plan.kind} recovery exhausted its retry budget`));
2334
- diagnostics.record({ event: "recovery_exhausted", state: model.state, epoch: model.epoch, code });
2335
- yield* stopPorts();
2336
- yield* transition("failed");
2552
+ const waitForTransportChange = (kind) => Effect2.callback((resume) => {
2553
+ const sync = model.sync;
2554
+ const media = model.media;
2555
+ const syncPhase2 = sync?.getSnapshot().connection.phase;
2556
+ const mediaPhase2 = media?.getSnapshot().connection.phase;
2557
+ const observe = () => {
2558
+ if (model.recoveryQueued !== kind || model.sync !== sync || model.media !== media || model.sync?.getSnapshot().connection.phase !== syncPhase2 || model.media?.getSnapshot().connection.phase !== mediaPhase2 || syncPhase2 === "live" && mediaPhase2 === "live") resume(Effect2.void);
2559
+ };
2560
+ listeners.add(observe);
2561
+ const unsubscribeSync = sync?.subscribe(observe);
2562
+ const unsubscribeMedia = media?.subscribe(observe);
2563
+ observe();
2564
+ return Effect2.sync(() => {
2565
+ listeners.delete(observe);
2566
+ unsubscribeSync?.();
2567
+ unsubscribeMedia?.();
2568
+ });
2337
2569
  });
2338
- const recoverMedia = () => Effect2.gen(function* () {
2570
+ const waitForMediaLive = (media) => Effect2.callback((resume) => {
2571
+ const observe = () => {
2572
+ const snapshot = media.getSnapshot();
2573
+ if (snapshot.connection.phase === "live") resume(Effect2.void);
2574
+ else if (snapshot.connection.phase === "failed") resume(Effect2.fail(lifecycleFailure("media_start_failed", snapshot.failure?.recoverable ?? true, "Media transport needs recovery")));
2575
+ };
2576
+ const unsubscribe = media.subscribe(observe);
2577
+ observe();
2578
+ return Effect2.sync(unsubscribe);
2579
+ });
2580
+ const recoverMedia = (attempt) => Effect2.gen(function* () {
2339
2581
  const media = model.media;
2340
- const sync = model.sync;
2341
- if (!media || !sync) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Media recovery requires active ports"));
2342
- const grant = yield* access.refresh("media_recovery", true).pipe(Effect2.mapError(accessFailure));
2582
+ if (!media) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Media recovery requires active ports"));
2583
+ if (media.getSnapshot().connection.phase === "live") {
2584
+ attempt.expiresAt = null;
2585
+ return;
2586
+ }
2587
+ if (media.getSnapshot().connection.phase === "recovering") {
2588
+ const now = yield* Clock2.currentTimeMillis;
2589
+ attempt.expiresAt ??= now + MEDIA_RECOVERY_TIMEOUT_MS;
2590
+ attempt.expiresAt = Math.max(attempt.expiresAt, foregroundRecoveryDeadline);
2591
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
2592
+ if (now < attempt.expiresAt) return yield* waitForMediaLive(media);
2593
+ }
2594
+ recordReconnect(options.recordReconnect, "media_decision", { strategy: "full_rebuild" });
2595
+ const grant = yield* traceRecovery("access_refresh", access.refresh("media_recovery", true).pipe(Effect2.mapError(accessFailure)));
2343
2596
  const restartInput = grant.media.provider === "cloudflare_sfu" ? grant.media.clientPayload : grant.media;
2344
- yield* foreign6(() => media.restart(restartInput));
2345
- model.mediaSnapshot = media.getSnapshot();
2597
+ attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
2598
+ yield* traceRecovery(
2599
+ "media_rebuild",
2600
+ foreign7(() => media.restart(restartInput), "media_start_failed", "Media transport could not restart")
2601
+ );
2602
+ yield* waitForMediaLive(media);
2603
+ });
2604
+ const syncAttemptExpired = (attempt, sync, now) => {
2605
+ if (attempt.client !== sync) {
2606
+ attempt.client = sync;
2607
+ attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
2608
+ attempt.started = true;
2609
+ }
2610
+ attempt.expiresAt = Math.max(attempt.expiresAt ?? 0, foregroundRecoveryDeadline);
2611
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
2612
+ return now >= attempt.expiresAt && sync.getSnapshot().connection.phase !== "live";
2613
+ };
2614
+ const syncNeedsReplacement = (snapshot) => {
2615
+ if (snapshot.connection.phase === "idle" || snapshot.connection.phase === "stopped") return Effect2.succeed(true);
2616
+ if (snapshot.connection.phase !== "terminal") return Effect2.succeed(false);
2617
+ const failure3 = syncTerminalFailure(snapshot);
2618
+ return failure3.recoverable ? Effect2.succeed(true) : Effect2.fail(failure3);
2619
+ };
2620
+ const recoverSync = (attempt) => Effect2.gen(function* () {
2621
+ const sync = model.sync;
2622
+ if (!sync) return yield* restartSync(attempt);
2623
+ const snapshot = sync.getSnapshot();
2624
+ const now = yield* Clock2.currentTimeMillis;
2625
+ if (syncAttemptExpired(attempt, sync, now)) {
2626
+ if (!attempt.started || !sync.restartTransport) return yield* restartSync(attempt);
2627
+ sync.restartTransport();
2628
+ attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
2629
+ }
2630
+ if ((attempt.started || snapshot.connection.phase !== "idle") && (yield* syncNeedsReplacement(snapshot))) return yield* restartSync(attempt);
2346
2631
  yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
2347
2632
  });
2348
- const recoverSync = () => Effect2.gen(function* () {
2349
- const media = model.media;
2350
- const scope = model.activeScope;
2351
- const grant = access.currentUnsafe();
2352
- if (!media || !scope || !grant) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery requires active ports"));
2633
+ const restartSync = (attempt) => Effect2.gen(function* () {
2634
+ const epoch = model.epoch;
2353
2635
  yield* access.getSyncToken("sync_recovery").pipe(Effect2.mapError(accessFailure));
2354
- model.syncBindingCleanup?.();
2355
- model.sync = null;
2356
- const sync = yield* Effect2.try({
2357
- try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry }),
2358
- catch: (cause) => lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
2359
- });
2360
- model.sync = sync;
2361
- yield* bindSync(scope, sync);
2362
- yield* foreign6(() => sync.start());
2636
+ const sync = yield* enqueue(
2637
+ Effect2.gen(function* () {
2638
+ const media = model.media;
2639
+ const scope = model.activeScope;
2640
+ const grant = access.currentUnsafe();
2641
+ if (model.epoch !== epoch || !active(model) || !media || !scope || !grant) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery requires active ports"));
2642
+ model.syncBindingCleanup?.();
2643
+ model.sync = null;
2644
+ const sync2 = yield* Effect2.try({
2645
+ try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, telemetry: options.telemetry, recordReconnect: options.recordReconnect }),
2646
+ catch: (cause) => lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
2647
+ });
2648
+ model.sync = sync2;
2649
+ yield* bindSync(scope, sync2);
2650
+ return sync2;
2651
+ })
2652
+ );
2653
+ attempt.client = sync;
2654
+ attempt.started = false;
2655
+ attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
2656
+ yield* foreign7(
2657
+ () => sync.start().then(() => {
2658
+ if (sync !== model.sync || model.epoch !== epoch) sync.stop();
2659
+ else if (attempt.client === sync) attempt.started = true;
2660
+ })
2661
+ ).pipe(
2662
+ Effect2.tapError(
2663
+ () => enqueue(
2664
+ Effect2.gen(function* () {
2665
+ if (sync !== model.sync) return;
2666
+ model.syncBindingCleanup?.();
2667
+ model.sync = null;
2668
+ model.syncSnapshot = null;
2669
+ yield* publish();
2670
+ })
2671
+ )
2672
+ )
2673
+ );
2363
2674
  yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
2364
- yield* emitPorts();
2365
2675
  });
2366
2676
  const handleSyncSnapshot = (sync, snapshot) => Effect2.gen(function* handleSyncSnapshotEffect() {
2367
2677
  if (!isCurrentSyncSnapshot(sync, snapshot)) return;
2368
2678
  model.syncSnapshot = snapshot;
2369
2679
  if (syncSubjectMismatched(access.currentUnsafe()?.subject ?? null, snapshot)) return yield* failForSnapshot(lifecycleFailure("invalid_access", false, "Sync authenticated a different participant subject"));
2370
- if (episodeEnded(snapshot)) return yield* failForSnapshot(lifecycleFailure("episode_ended", false, "The Episode has ended"));
2680
+ if (yield* settleTerminalSnapshot(snapshot)) return;
2371
2681
  if (syncNeedsRecovery(snapshot)) yield* recover("sync");
2372
2682
  yield* publish();
2373
2683
  });
2684
+ const settleTerminalSnapshot = (snapshot) => Effect2.gen(function* () {
2685
+ const ended = episodeEnded(snapshot);
2686
+ if (ended || snapshot.connection.terminalReason === "participant_inactive") {
2687
+ if (ended) model.episodeEndConfirmed = true;
2688
+ yield* finishTerminalSnapshot();
2689
+ return true;
2690
+ }
2691
+ if (snapshot.connection.phase !== "terminal") return false;
2692
+ const failure3 = syncTerminalFailure(snapshot);
2693
+ if (failure3.recoverable) return false;
2694
+ yield* failForSnapshot(failure3);
2695
+ return true;
2696
+ });
2374
2697
  const isCurrentSyncSnapshot = (sync, snapshot) => sync === model.sync && snapshot !== model.syncSnapshot;
2375
2698
  const syncSubjectMismatched = (subject, snapshot) => subject !== null && snapshot.participantId !== null && (subject.participantId !== snapshot.participantId || subject.participantGeneration !== snapshot.participantGeneration);
2376
- const episodeEnded = (snapshot) => snapshot.control?.status === "ended" || snapshot.optimisticControl?.status === "ended";
2377
- const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "terminal" || snapshot.connection.phase === "recovering");
2378
- const failForSnapshot = (failure2) => Effect2.gen(function* failForSnapshotEffect() {
2379
- model.failure = toFailure(failure2);
2699
+ const episodeEnded = (snapshot) => snapshot.control?.status === "ended" || snapshot.connection.terminalReason === "episode_ended";
2700
+ const finishTerminalSnapshot = () => Effect2.gen(function* () {
2701
+ model.failure = null;
2702
+ yield* transition("leaving");
2703
+ yield* stopPorts();
2704
+ yield* transition("left");
2705
+ });
2706
+ const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
2707
+ const recordFailure = (failure3) => Effect2.sync(() => {
2708
+ const reason = model.sync?.getSnapshot().connection.terminalReason;
2709
+ const terminalReason = reason === "participant_inactive" || reason === "stale_participant_generation" || reason === "episode_ended" ? reason : failure3.code;
2710
+ diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure3.code });
2711
+ recordReconnect(options.recordReconnect, "terminal_failure", { code: failure3.code, terminal_reason: terminalReason }, "failed");
2712
+ });
2713
+ const failForSnapshot = (failure3) => Effect2.gen(function* failForSnapshotEffect() {
2714
+ model.failure = toFailure(failure3);
2715
+ yield* recordFailure(failure3);
2380
2716
  yield* stopPorts();
2381
2717
  yield* transition("failed");
2382
2718
  });
@@ -2384,21 +2720,43 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2384
2720
  if (media !== model.media || media.getSnapshot() !== snapshot) return;
2385
2721
  if (snapshot === model.mediaSnapshot) return;
2386
2722
  model.mediaSnapshot = snapshot;
2387
- if (active(model) && snapshot.connection.phase === "failed" && snapshot.failure?.recoverable) yield* recover("media");
2723
+ if (active(model) && (snapshot.connection.phase === "recovering" || snapshot.connection.phase === "failed" && snapshot.failure?.recoverable)) yield* recover("media");
2388
2724
  yield* publish();
2389
2725
  });
2390
2726
  const handleMediaFailure = () => model.media ? handleMediaSnapshot(model.media, model.media.getSnapshot()) : Effect2.void;
2391
2727
  const applyAutomaticMediaReplacement = (grant) => Effect2.gen(function* () {
2392
2728
  const media = model.media;
2393
- if (!media || !active(model)) return;
2729
+ const scope = model.activeScope;
2730
+ const epoch = model.epoch;
2731
+ if (!media || !scope || !active(model)) return;
2394
2732
  const restartInput = grant.media.provider === "cloudflare_sfu" ? grant.media.clientPayload : grant.media;
2395
- const restarted = yield* Effect2.exit(foreign6(() => media.restart(restartInput), "media_start_failed", "The media client could not apply a replacement access grant"));
2396
- if (restarted._tag === "Failure") {
2397
- yield* recover("media");
2398
- return;
2399
- }
2400
- model.mediaSnapshot = media.getSnapshot();
2401
- yield* publish();
2733
+ mediaAttempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
2734
+ yield* Effect2.forkIn(
2735
+ foreign7(
2736
+ () => media.restart(restartInput).then(() => {
2737
+ if (media !== model.media || model.epoch !== epoch) media.stop();
2738
+ }),
2739
+ "media_start_failed",
2740
+ "The media client could not apply a replacement access grant"
2741
+ ).pipe(
2742
+ (effect) => withinForegroundBudget(effect, MEDIA_RECOVERY_TIMEOUT_MS, lifecycleFailure("media_start_failed", true, "Automatic media replacement did not finish")),
2743
+ Effect2.match({ onSuccess: () => true, onFailure: () => false }),
2744
+ Effect2.flatMap(
2745
+ (restarted) => enqueue(
2746
+ Effect2.gen(function* () {
2747
+ if (model.epoch !== epoch || media !== model.media || !active(model)) return;
2748
+ if (!restarted) {
2749
+ yield* recover("media");
2750
+ return;
2751
+ }
2752
+ model.mediaSnapshot = media.getSnapshot();
2753
+ yield* publish();
2754
+ })
2755
+ )
2756
+ )
2757
+ ),
2758
+ scope
2759
+ );
2402
2760
  });
2403
2761
  const notifyScreenEnded = () => Effect2.sync(() => {
2404
2762
  if (!active(model)) return;
@@ -2419,7 +2777,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2419
2777
  }) : Deferred.complete(work.deferred, work.effect).pipe(Effect2.asVoid);
2420
2778
  yield* Effect2.forkScoped(Queue.take(queue).pipe(Effect2.flatMap(process), Effect2.forever));
2421
2779
  const accessReplacementUnsubscribe = yield* access.subscribeAutomaticMediaReplacement((grant) => enqueueBackground(applyAutomaticMediaReplacement(grant)));
2422
- const foregroundUnsubscribe = platform.subscribeForeground?.(() => enqueueBackground(refreshAccess())) ?? null;
2780
+ const foregroundUnsubscribe = platform.subscribeForeground?.(() => {
2781
+ foregroundRecoveryDeadline = platform.clock.now() + MEDIA_RECOVERY_TIMEOUT_MS;
2782
+ enqueueBackground(refreshAccess());
2783
+ }) ?? null;
2423
2784
  yield* Effect2.addFinalizer(
2424
2785
  () => Effect2.gen(function* () {
2425
2786
  model.closed = true;
@@ -2448,8 +2809,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2448
2809
  },
2449
2810
  getDiagnostics: () => diagnostics.snapshot(),
2450
2811
  getJoinTrace: () => diagnostics.joinTrace(),
2451
- setInitialMedia: (provider) => Effect2.sync(() => {
2812
+ setInitialMedia: (provider, intent) => Effect2.sync(() => {
2452
2813
  model.initialMedia = provider;
2814
+ model.initialMediaIntent = intent ?? null;
2453
2815
  }),
2454
2816
  configureJoin: (intent) => Effect2.sync(() => {
2455
2817
  if (model.state !== "idle" && model.state !== "left" && model.state !== "failed") throw lifecycleFailure("invalid_state", false, `Cannot configure Join while ${model.state}`);
@@ -2479,13 +2841,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2479
2841
  runCommand: (operation) => enqueue(
2480
2842
  Effect2.gen(function* () {
2481
2843
  const ports = portsFor(model);
2482
- if (model.state !== "live" || !ports) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, `Cannot run a command while ${model.state}`));
2483
- const value = yield* withFreshAccess(() => operation(ports));
2484
- if (model.state !== "live") return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The command belongs to an inactive Connection"));
2485
- return value;
2844
+ if (!active(model) || !ports || ports.sync.getSnapshot().connection.phase !== "live") return yield* Effect2.fail(lifecycleFailure("invalid_state", true, "Connection interrupted. Try again when reconnected."));
2845
+ return ports;
2486
2846
  })
2847
+ ).pipe(
2848
+ Effect2.flatMap(
2849
+ (ports) => withFreshAccess(
2850
+ () => Effect2.suspend(() => {
2851
+ if (!active(model) || ports.sync !== model.sync) return Effect2.fail(lifecycleFailure("invalid_state", true, "Connection interrupted. Try again when reconnected."));
2852
+ return operation(ports).pipe(Effect2.flatMap((value) => active(model) && ports.sync === model.sync ? Effect2.succeed(value) : Effect2.fail(lifecycleFailure("invalid_state", true, "The command belongs to an inactive Connection"))));
2853
+ })
2854
+ )
2855
+ )
2487
2856
  ),
2488
- runPortCommand: (operation) => enqueue(withFreshAccess(operation)),
2857
+ runPortCommand: (operation) => withFreshAccess(operation),
2489
2858
  nowUnsafe: () => clock.currentTimeMillisUnsafe(),
2490
2859
  scheduleUnsafe: (callback, milliseconds) => platform.clock.setTimeout(callback, milliseconds),
2491
2860
  cancelScheduleUnsafe: (handle) => platform.clock.clearTimeout(handle),
@@ -2502,8 +2871,9 @@ function snapshotFor(model, access) {
2502
2871
  state: model.state,
2503
2872
  subject: subjectFor(access),
2504
2873
  episode: episodeFor(access, control),
2505
- connection: Object.freeze({ sync: syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
2506
- failure: model.failure ? Object.freeze({ ...model.failure }) : null
2874
+ connection: Object.freeze({ sync: model.syncSnapshot?.connection.phase === "live" && model.syncNoticeUnresponsive ? "unresponsive" : syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
2875
+ failure: model.failure ? Object.freeze({ ...model.failure }) : null,
2876
+ episodeEnded: model.episodeEndConfirmed
2507
2877
  });
2508
2878
  }
2509
2879
  function subjectFor(access) {
@@ -2522,7 +2892,7 @@ function portsFor(model) {
2522
2892
  return model.sync && model.media ? { sync: model.sync, media: model.media } : null;
2523
2893
  }
2524
2894
  function active(model) {
2525
- return model.state === "live" || model.state === "reconnecting";
2895
+ return !model.closed && (model.state === "live" || model.state === "reconnecting");
2526
2896
  }
2527
2897
  function lifecycleFailure(code, recoverable, message, cause) {
2528
2898
  return new ConnectionLifecycleFailure({ code, recoverable, message, ...cause === void 0 ? {} : { cause } });
@@ -2531,11 +2901,60 @@ function toFailure(value) {
2531
2901
  return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message });
2532
2902
  }
2533
2903
  function accessFailure(value) {
2534
- return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", "Access was rejected", value.cause);
2904
+ return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", value.code === "access.invalid" ? "Access was rejected" : "Could not reach the Space. Check your network and try again.", value.cause);
2535
2905
  }
2536
2906
  function captureFailure(cause) {
2537
- if (cause instanceof DOMException && cause.name === "NotAllowedError") return lifecycleFailure("permission_denied", true, "Media permission was denied", cause);
2538
- return lifecycleFailure("unsupported_environment", false, "Browser media capture is unavailable", cause);
2907
+ if (cause instanceof ConnectionError && cause.cause === void 0) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
2908
+ const failure3 = describeMediaCaptureError(cause instanceof ConnectionError ? cause.cause : cause);
2909
+ if (failure3.code === "unsupported_environment") return lifecycleFailure("media_capture_failed", true, "Media capture failed. Try again or enter with devices off.", cause);
2910
+ return lifecycleFailure(failure3.code, failure3.recoverable, failure3.message, cause);
2911
+ }
2912
+ function acquireInitialMedia(provider, intent) {
2913
+ return provider(intent).pipe(
2914
+ Effect2.map((stream) => ({ stream, failure: null })),
2915
+ Effect2.catch((cause) => {
2916
+ const initialFailure = captureFailure(cause);
2917
+ if (!initialFailure.recoverable) return Effect2.fail(initialFailure);
2918
+ const tracks = /* @__PURE__ */ new Set();
2919
+ return Effect2.gen(function* () {
2920
+ const failures = [];
2921
+ for (const source of ["microphone", "camera"]) {
2922
+ if (!intent[source]) continue;
2923
+ if (!intent.microphone || !intent.camera) {
2924
+ failures.push({ source, failure: initialFailure });
2925
+ continue;
2926
+ }
2927
+ yield* provider({ microphone: source === "microphone", camera: source === "camera" }).pipe(
2928
+ Effect2.tap(
2929
+ (stream) => Effect2.sync(() => {
2930
+ for (const track of stream.getTracks()) tracks.add(track);
2931
+ })
2932
+ ),
2933
+ Effect2.catch(
2934
+ (cause2) => Effect2.sync(() => {
2935
+ failures.push({ source, failure: captureFailure(cause2) });
2936
+ })
2937
+ )
2938
+ );
2939
+ }
2940
+ if (tracks.size === 0) yield* provider({ microphone: false, camera: false }).pipe(Effect2.mapError(captureFailure));
2941
+ return { stream: streamFromTracks([...tracks]), failure: initialCaptureWarning(failures) };
2942
+ }).pipe(
2943
+ Effect2.onInterrupt(
2944
+ () => Effect2.sync(() => {
2945
+ for (const track of tracks) track.stop();
2946
+ })
2947
+ )
2948
+ );
2949
+ })
2950
+ );
2951
+ }
2952
+ function initialCaptureWarning(failures) {
2953
+ const first = failures[0];
2954
+ if (!first) return null;
2955
+ const names = failures.length === 2 ? "Microphone and camera" : first.source === "microphone" ? "Microphone" : "Camera";
2956
+ const message = failures.length === 2 && first.failure.code !== failures[1]?.failure.code ? failures.map(({ source, failure: failure3 }) => `${source === "microphone" ? "Microphone" : "Camera"}: ${failure3.message}`).join(" ") : `${names}: ${first.failure.message}`;
2957
+ return lifecycleFailure(first.failure.code, true, message, first.failure.cause);
2539
2958
  }
2540
2959
  function failureFromCause(cause, code, message) {
2541
2960
  if (cause instanceof ConnectionLifecycleFailure) return cause;
@@ -2569,6 +2988,12 @@ function mediaPhase(phase) {
2569
2988
  if (phase === "connecting") return "connecting";
2570
2989
  return "idle";
2571
2990
  }
2991
+ function syncTerminalFailure(snapshot) {
2992
+ const reason = snapshot.connection.terminalReason;
2993
+ if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
2994
+ if (reason === "participant_inactive" || reason === "stale_participant_generation") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
2995
+ return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
2996
+ }
2572
2997
 
2573
2998
  // src/space-client/errors.ts
2574
2999
  import { Data as Data3 } from "effect";
@@ -2578,6 +3003,10 @@ var CONNECTION_ERROR_CODE_MAP = {
2578
3003
  invalid_access: "access.invalid",
2579
3004
  access_unavailable: "access.unavailable",
2580
3005
  permission_denied: "media.permission_denied",
3006
+ device_not_found: "media.device_not_found",
3007
+ device_busy: "media.device_busy",
3008
+ device_constraint_invalid: "media.device_constraint_invalid",
3009
+ media_capture_failed: "media.capture_failed",
2581
3010
  sync_start_failed: "connection.sync_start_failed",
2582
3011
  media_start_failed: "connection.media_start_failed",
2583
3012
  join_cleanup_unconfirmed: "connection.join_cleanup_unconfirmed",
@@ -2610,6 +3039,9 @@ function normalizeClientError(cause, fallback = "client.internal_error") {
2610
3039
  message: cause.message
2611
3040
  });
2612
3041
  }
3042
+ if (cause instanceof V1SyncError && ["signaling_timeout", "negotiation_timeout", "media_timeout"].includes(cause.code)) {
3043
+ return new SpaceClientError({ code: "media.timeout", recoverable: true, message: "Media did not respond in time. Try again." });
3044
+ }
2613
3045
  if (isAccessInvalid(cause)) {
2614
3046
  return new SpaceClientError({ code: "access.invalid", recoverable: true, message: "The access grant was rejected" });
2615
3047
  }
@@ -2633,6 +3065,7 @@ function connectionErrorCode(code, action) {
2633
3065
 
2634
3066
  // src/space-client/store.ts
2635
3067
  import { Context as Context4, Effect as Effect3, Layer as Layer4, SubscriptionRef as SubscriptionRef2 } from "effect";
3068
+ var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering"]);
2636
3069
  var empty = (value) => Object.freeze(value);
2637
3070
  var EMPTY = empty({
2638
3071
  connection: empty({ status: "idle", episode: null, lastError: null }),
@@ -2648,6 +3081,8 @@ var EMPTY = empty({
2648
3081
  }),
2649
3082
  chat: empty({ status: "idle", messages: empty([]), pendingSends: empty([]), readReceipts: empty([]), unreadCount: 0, pagination: empty({ cursor: null, hasOlder: false, historyTruncated: false }), lastError: null }),
2650
3083
  reactions: empty({ active: empty([]) }),
3084
+ recording: empty({ current: null }),
3085
+ offline: empty({ pending: empty([]), decisionNeeded: false, policy: "send" }),
2651
3086
  whiteboard: empty({ open: false, engine: empty({ status: "unsubscribed", sceneId: null, revision: null, presenting: false, error: null }) })
2652
3087
  });
2653
3088
  var SpaceStore = class _SpaceStore {
@@ -2671,9 +3106,10 @@ var SpaceStore = class _SpaceStore {
2671
3106
  };
2672
3107
  updateConnection(snapshot) {
2673
3108
  const current = this.getSnapshot().connection;
2674
- const error2 = snapshot.failure ? empty({ code: mapCode(snapshot.failure.code), recoverable: snapshot.failure.recoverable, message: snapshot.failure.message }) : null;
2675
- if (current.status === snapshot.state && sameEpisode(current.episode, snapshot.episode) && sameError(current.lastError, error2)) return;
2676
- this.#replace("connection", empty({ status: snapshot.state, episode: snapshot.episode, lastError: error2 }));
3109
+ const error2 = snapshot.failure ? empty({ code: publicCode(snapshot.failure.code), recoverable: snapshot.failure.recoverable, message: snapshot.failure.message }) : null;
3110
+ const status = snapshot.state === "live" && (INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.sync) || INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.media)) ? "reconnecting" : snapshot.state;
3111
+ if (current.status === status && sameEpisode(current.episode, snapshot.episode) && sameError(current.lastError, error2)) return;
3112
+ this.#replace("connection", empty({ status, episode: snapshot.episode, lastError: sameError(current.lastError, error2) ? current.lastError : error2 }));
2677
3113
  }
2678
3114
  updateSelf(value) {
2679
3115
  this.#replace("self", value);
@@ -2690,6 +3126,12 @@ var SpaceStore = class _SpaceStore {
2690
3126
  updateReactions(value) {
2691
3127
  this.#replace("reactions", value);
2692
3128
  }
3129
+ updateRecording(value) {
3130
+ this.#replace("recording", value);
3131
+ }
3132
+ updateOffline(value) {
3133
+ this.#replace("offline", value);
3134
+ }
2693
3135
  updateWhiteboard(value) {
2694
3136
  this.#replace("whiteboard", value);
2695
3137
  }
@@ -2715,7 +3157,16 @@ var SpaceStoreService = class extends Context4.Service()("@chalk/client/SpaceSto
2715
3157
  var makeSpaceStoreLayer = Layer4.effect(SpaceStoreService, SpaceStore.make());
2716
3158
  var makeFakeSpaceStoreLayer = (store = new SpaceStore()) => Layer4.succeed(SpaceStoreService, store);
2717
3159
  function mapCode(code) {
2718
- return { invalid_access: "access.invalid", episode_ended: "episode.ended", invalid_payload: "chat.payload_invalid", permission_denied: "media.permission_denied", unsupported_environment: "environment.unsupported", rate_limited: "command.rate_limited", command_rejected: "command.rejected" }[code] ?? "client.internal_error";
3160
+ return {
3161
+ invalid_access: "access.invalid",
3162
+ access_unavailable: "access.unavailable",
3163
+ episode_ended: "episode.ended",
3164
+ invalid_payload: "chat.payload_invalid",
3165
+ permission_denied: "media.permission_denied",
3166
+ unsupported_environment: "environment.unsupported",
3167
+ rate_limited: "command.rate_limited",
3168
+ command_rejected: "command.rejected"
3169
+ }[code] ?? "client.internal_error";
2719
3170
  }
2720
3171
  function sameEpisode(left, right) {
2721
3172
  if (left === right) return true;
@@ -2727,49 +3178,995 @@ function sameError(left, right) {
2727
3178
  if (!left || !right) return false;
2728
3179
  return left.code === right.code && left.recoverable === right.recoverable && left.message === right.message;
2729
3180
  }
3181
+ function publicCode(code) {
3182
+ switch (code) {
3183
+ case "device_not_found":
3184
+ return "media.device_not_found";
3185
+ case "device_busy":
3186
+ return "media.device_busy";
3187
+ case "device_constraint_invalid":
3188
+ return "media.device_constraint_invalid";
3189
+ case "media_capture_failed":
3190
+ return "media.capture_failed";
3191
+ default:
3192
+ return mapCode(code);
3193
+ }
3194
+ }
2730
3195
 
2731
- // src/space-client/core.ts
2732
- import { Clock as Clock5, Context as Context11, Duration as Duration2, Effect as Effect10, Layer as Layer11 } from "effect";
2733
-
2734
- // src/space-client/controller-effects.ts
2735
- import { Context as Context10, Effect as Effect9, Layer as Layer10 } from "effect";
2736
- import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
2737
-
2738
- // src/space-client/chat-controller.ts
2739
- import { Context as Context5, Effect as Effect4, Layer as Layer5 } from "effect";
3196
+ // src/feedback/types.ts
3197
+ var FEEDBACK_EVIDENCE_SCHEMA_VERSION = "FeedbackEvidence/v1";
3198
+ var FEEDBACK_REQUEST_SCHEMA_VERSION = "FeedbackReportRequest/v1";
3199
+ var FEEDBACK_RECEIPT_SCHEMA_VERSION = "FeedbackReportReceipt/v1";
3200
+ var FEEDBACK_SCREENSHOT_SCHEMA_VERSION = "FeedbackScreenshot/v1";
3201
+ var FEEDBACK_LOCAL_STATE_REGISTRY_VERSION = "FeedbackLocalState/v1";
3202
+ var FEEDBACK_COOKIE_REGISTRY_VERSION = "FeedbackCookies/v1";
3203
+ var FEEDBACK_MAX_MESSAGE_BYTES = 8e3;
3204
+ var FEEDBACK_MAX_EVIDENCE_BYTES = 128 * 1024;
3205
+ var FEEDBACK_MAX_REQUEST_BYTES = 1 << 20;
3206
+ var FEEDBACK_MAX_SCREENSHOT_BYTES = 450 * 1024;
3207
+ var FEEDBACK_MAX_SCREENSHOT_WIDTH = 1920;
3208
+ var FEEDBACK_MAX_SCREENSHOT_HEIGHT = 1080;
3209
+ var FEEDBACK_MAX_TELEMETRY_EVENTS = 50;
3210
+ var FEEDBACK_MAX_DIAGNOSTIC_EVENTS = 50;
3211
+ var FEEDBACK_MAX_LOCAL_STATE_ENTRIES = 32;
3212
+ var FEEDBACK_MAX_COOKIE_ENTRIES = 16;
2740
3213
 
2741
- // src/space-client/chat-controller-helpers.ts
2742
- import { sha256 } from "@noble/hashes/sha2.js";
2743
- var MAX_CHAT_PAGE_SIZE = 100;
2744
- var MAX_LOADED_CHAT_MESSAGES = 500;
2745
- var MAX_CHAT_TEXT_BYTES = 16384;
2746
- var MAX_CHAT_TEXT_SCALARS = 4e3;
3214
+ // src/feedback/validation.ts
3215
+ import { isDiagnosticReference, parseDiagnosticEventDraft, redactDiagnosticAttributes } from "@q9labsai/diagnostics-contracts";
2747
3216
  var encoder3 = new TextEncoder();
2748
- var allowedMimeTypes = new Set(CHALK_CHAT_ATTACHMENT_MIME_TYPES);
2749
- function validateChatMessage(input) {
2750
- const attachments = input.attachments ?? [];
2751
- if (input.text.length === 0 && attachments.length === 0) return "A chat message requires text or an attachment";
2752
- if (Array.from(input.text).length > MAX_CHAT_TEXT_SCALARS || encoder3.encode(input.text).byteLength > MAX_CHAT_TEXT_BYTES) return `Chat text must not exceed ${MAX_CHAT_TEXT_SCALARS} characters or ${MAX_CHAT_TEXT_BYTES} bytes`;
2753
- if (attachments.length > CHALK_CHAT_ATTACHMENT_LIMITS.maximumPerMessage) return `A chat message supports at most ${CHALK_CHAT_ATTACHMENT_LIMITS.maximumPerMessage} attachments`;
2754
- if (new Set(attachments.map((attachment) => attachment.attachmentId)).size !== attachments.length) return "Chat attachment IDs must be unique";
2755
- return attachments.map(validateChatAttachment).find((value) => value !== null) ?? null;
3217
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
3218
+ var TRACE_ID_PATTERN = /^(?=.*[1-9a-f])[0-9a-f]{32}$/u;
3219
+ var SPAN_ID_PATTERN = /^(?=.*[1-9a-f])[0-9a-f]{16}$/u;
3220
+ var TRACEPARENT_PATTERN = /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/iu;
3221
+ var SAFE_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@+/=-]{0,127}$/u;
3222
+ var DASHBOARD_ACTION_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/u;
3223
+ var UNSAFE_VALUE_PATTERN = /(?:https?:\/\/|wss?:\/\/|bearer\s+[a-z0-9._~+\/-]+|-----begin|candidate:|v=0\r?\n|\b(?:\d{1,3}\.){3}\d{1,3}\b|[\w.+-]+@[\w.-]+\.[a-z]{2,})/iu;
3224
+ var TELEMETRY_KEYS = ["version", "event_id", "journey_id", "sequence", "occurred_at", "name", "phase", "state", "origin_kind", "first_observed_layer", "upstream_visibility", "parent_event_id", "trace_id", "span_id", "traceparent", "tracestate", "attributes"];
3225
+ var TELEMETRY_NAMES = ["journey.started", "journey.phase", "journey.terminal", "journey.linked", "http.request", "sync.frame", "rtc.summary", "diagnostic.timeline"];
3226
+ var TELEMETRY_PHASES = ["root", "authentication", "signaling", "media", "recovery", "terminal"];
3227
+ var TELEMETRY_STATES = ["started", "in_progress", "succeeded", "failed", "cancelled", "observed"];
3228
+ var TELEMETRY_ORIGINS = ["client", "diagnostic", "http", "rtc", "sync"];
3229
+ var TELEMETRY_VISIBILITY = ["local", "propagated"];
3230
+ var FeedbackValidationError = class extends Error {
3231
+ issues;
3232
+ constructor(message, issues = [{ path: "$", message }]) {
3233
+ super(message);
3234
+ this.name = "FeedbackValidationError";
3235
+ this.issues = issues;
3236
+ }
3237
+ };
3238
+ function validateFeedbackReportRequest(input) {
3239
+ const issues = [];
3240
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return failure2("$", "expected a feedback request object");
3241
+ checkKeys(input, ["schema_version", "category", "message", "source", "evidence", "screenshot"], issues, "$", false);
3242
+ if (property(input, "schema_version") !== FEEDBACK_REQUEST_SCHEMA_VERSION) issues.push({ path: "$.schema_version", message: "unsupported feedback request schema" });
3243
+ const fields = validateFeedbackRequestFields(input, issues);
3244
+ validateFeedbackScreenshotConsistency(fields, issues);
3245
+ if (!fields.category || !fields.source || !fields.message || !fields.evidence || !fields.screenshot_valid || issues.length > 0) return { ok: false, issues };
3246
+ const request = {
3247
+ schema_version: FEEDBACK_REQUEST_SCHEMA_VERSION,
3248
+ category: fields.category,
3249
+ message: fields.message,
3250
+ source: fields.source,
3251
+ evidence: fields.evidence,
3252
+ ...fields.screenshot ? { screenshot: fields.screenshot } : {}
3253
+ };
3254
+ return validateFeedbackRequestSize(request, issues);
3255
+ }
3256
+ function validateFeedbackRequestFields(input, issues) {
3257
+ const category = enumValue(property(input, "category"), ["bug", "feature_request", "other"], "$.category", issues);
3258
+ const source = enumValue(property(input, "source"), ["embedded", "chalk_web", "chalk_mobile", "dashboard"], "$.source", issues);
3259
+ const message = validateMessage(property(input, "message"), issues);
3260
+ const evidenceResult = validateFeedbackEvidence(property(input, "evidence"));
3261
+ appendIssues(issues, evidenceResult);
3262
+ const screenshotResult = validateRequestScreenshot(property(input, "screenshot"));
3263
+ appendIssues(issues, screenshotResult);
3264
+ return {
3265
+ category,
3266
+ source,
3267
+ message,
3268
+ evidence: evidenceResult.ok ? evidenceResult.value : void 0,
3269
+ screenshot: screenshotResult.ok ? screenshotResult.value : void 0,
3270
+ screenshot_valid: screenshotResult.ok
3271
+ };
2756
3272
  }
2757
- function validateChatUpload(file, bytes, clientAttachmentId) {
2758
- const fileName = "fileName" in file ? file.fileName : file.name;
2759
- const mimeType = "mimeType" in file ? file.mimeType : file.type;
2760
- const error2 = validateChatAttachment({ attachmentId: clientAttachmentId, fileName, mimeType, byteLength: bytes.byteLength });
2761
- return error2 ?? { fileName, mimeType };
3273
+ function validateRequestScreenshot(input) {
3274
+ return input === void 0 ? { ok: true, value: void 0 } : validateFeedbackScreenshot(input);
3275
+ }
3276
+ function validateFeedbackScreenshotConsistency(fields, issues) {
3277
+ if (!fields.evidence) return;
3278
+ if (!fields.screenshot && requiresScreenshot(fields.evidence)) {
3279
+ issues.push({ path: "$.screenshot", message: "a captured screenshot payload is required" });
3280
+ return;
3281
+ }
3282
+ if (!fields.screenshot) return;
3283
+ if (!requiresScreenshot(fields.evidence)) issues.push({ path: "$.evidence.screenshot.state", message: "screenshot state must indicate a capture" });
3284
+ if (fields.evidence.screenshot.captured_at !== fields.screenshot.captured_at) issues.push({ path: "$.screenshot.captured_at", message: "screenshot timestamps must match" });
3285
+ }
3286
+ function requiresScreenshot(evidence) {
3287
+ return evidence.screenshot.state === "captured" || evidence.screenshot.state === "partial";
3288
+ }
3289
+ function validateFeedbackRequestSize(request, issues) {
3290
+ if (encodedByteLength(request) > FEEDBACK_MAX_REQUEST_BYTES) issues.push({ path: "$", message: "feedback request exceeds 1 MiB" });
3291
+ return issues.length === 0 ? { ok: true, value: request } : { ok: false, issues };
3292
+ }
3293
+ function appendIssues(issues, result) {
3294
+ if (!result.ok) issues.push(...result.issues);
3295
+ }
3296
+ function parseFeedbackReportRequest(input) {
3297
+ const result = validateFeedbackReportRequest(input);
3298
+ if (result.ok) return result.value;
3299
+ throw new FeedbackValidationError("Invalid feedback request", result.issues);
3300
+ }
3301
+ function validateFeedbackEvidence(input) {
3302
+ const issues = [];
3303
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return failure2("$", "expected a feedback evidence object");
3304
+ checkKeys(input, ["schema_version", "collected_at", "app", "sdk", "platform", "connection", "scope", "correlations", "diagnostics", "local_state", "cookies", "screenshot"], issues, "$", false);
3305
+ if (property(input, "schema_version") !== FEEDBACK_EVIDENCE_SCHEMA_VERSION) issues.push({ path: "$.schema_version", message: "unsupported feedback evidence schema" });
3306
+ const app = validateApp(property(input, "app"), issues);
3307
+ const sdk = validateSDK(property(input, "sdk"), issues);
3308
+ const platform = validatePlatform(property(input, "platform"), issues);
3309
+ const connection = validateConnection(property(input, "connection"), issues);
3310
+ const scope = validateScope(property(input, "scope"), issues);
3311
+ const correlations = validateCorrelations(property(input, "correlations"), issues);
3312
+ const diagnostics = validateDiagnostics(property(input, "diagnostics"), issues);
3313
+ const localState = validateLocalState(property(input, "local_state"), issues);
3314
+ const cookies = validateCookies(property(input, "cookies"), issues);
3315
+ const screenshot = validateScreenshotState(property(input, "screenshot"), issues);
3316
+ if (!sdk || !platform || !correlations || !diagnostics || !localState || !cookies || !screenshot || issues.length > 0) return { ok: false, issues };
3317
+ const collectedAt = checkDateTimeValue(property(input, "collected_at"), "$.collected_at", issues);
3318
+ if (!collectedAt) return { ok: false, issues };
3319
+ const evidence = {
3320
+ schema_version: FEEDBACK_EVIDENCE_SCHEMA_VERSION,
3321
+ collected_at: collectedAt,
3322
+ ...app ? { app } : {},
3323
+ sdk,
3324
+ platform,
3325
+ ...connection ? { connection } : {},
3326
+ ...scope ? { scope } : {},
3327
+ correlations,
3328
+ diagnostics,
3329
+ local_state: localState,
3330
+ cookies,
3331
+ screenshot
3332
+ };
3333
+ if (encodedByteLength(evidence) > FEEDBACK_MAX_EVIDENCE_BYTES) return failure2("$", "feedback evidence exceeds 128 KiB");
3334
+ return { ok: true, value: evidence };
3335
+ }
3336
+ function parseFeedbackEvidence(input) {
3337
+ const result = validateFeedbackEvidence(input);
3338
+ if (result.ok) return result.value;
3339
+ throw new FeedbackValidationError("Invalid feedback evidence", result.issues);
3340
+ }
3341
+ function validateFeedbackScreenshot(input) {
3342
+ const issues = [];
3343
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return failure2("$", "expected a screenshot object");
3344
+ checkKeys(input, ["schema_version", "mime_type", "width", "height", "captured_at", "data_base64"], issues, "$", false);
3345
+ if (property(input, "schema_version") !== FEEDBACK_SCREENSHOT_SCHEMA_VERSION) issues.push({ path: "$.schema_version", message: "unsupported screenshot schema" });
3346
+ const mimeType = enumValue(property(input, "mime_type"), ["image/jpeg", "image/png", "image/webp"], "$.mime_type", issues);
3347
+ const width = boundedInteger2(property(input, "width"), 1, FEEDBACK_MAX_SCREENSHOT_WIDTH, "$.width", issues);
3348
+ const height = boundedInteger2(property(input, "height"), 1, FEEDBACK_MAX_SCREENSHOT_HEIGHT, "$.height", issues);
3349
+ const capturedAt = property(input, "captured_at");
3350
+ checkDateTime(capturedAt, "$.captured_at", issues);
3351
+ const dataInput = property(input, "data_base64");
3352
+ const data = typeof dataInput === "string" ? dataInput : void 0;
3353
+ if (!data || !validBase64(data)) issues.push({ path: "$.data_base64", message: "screenshot data must be base64" });
3354
+ else if (base64ByteLength(data) > FEEDBACK_MAX_SCREENSHOT_BYTES) issues.push({ path: "$.data_base64", message: "screenshot exceeds 450 KiB" });
3355
+ if (!mimeType || width === void 0 || height === void 0 || typeof capturedAt !== "string" || !data || issues.length > 0) return { ok: false, issues };
3356
+ return { ok: true, value: { schema_version: FEEDBACK_SCREENSHOT_SCHEMA_VERSION, mime_type: mimeType, width, height, captured_at: capturedAt, data_base64: data } };
3357
+ }
3358
+ function parseFeedbackScreenshot(input) {
3359
+ const result = validateFeedbackScreenshot(input);
3360
+ if (result.ok) return result.value;
3361
+ throw new FeedbackValidationError("Invalid feedback screenshot", result.issues);
3362
+ }
3363
+ function assertFeedbackReceipt(input) {
3364
+ const issues = [];
3365
+ if (typeof input !== "object" || input === null || Array.isArray(input)) throw new FeedbackValidationError("Invalid feedback receipt");
3366
+ checkKeys(input, ["schema_version", "id", "submitted_at"], issues, "$", false);
3367
+ const schemaVersion = property(input, "schema_version");
3368
+ const id = property(input, "id");
3369
+ const submittedAt = property(input, "submitted_at");
3370
+ if (schemaVersion !== FEEDBACK_RECEIPT_SCHEMA_VERSION) issues.push({ path: "$.schema_version", message: "unsupported receipt schema" });
3371
+ if (!isUUID(id)) issues.push({ path: "$.id", message: "receipt ID is not a valid ID" });
3372
+ checkDateTime(submittedAt, "$.submitted_at", issues);
3373
+ if (issues.length > 0 || typeof id !== "string" || typeof submittedAt !== "string") throw new FeedbackValidationError("Invalid feedback receipt", issues);
3374
+ return { schema_version: FEEDBACK_RECEIPT_SCHEMA_VERSION, id, submitted_at: submittedAt };
3375
+ }
3376
+ function assertFeedbackMessage(message) {
3377
+ const issues = [];
3378
+ const normalized = validateMessage(message, issues);
3379
+ if (!normalized) throw new FeedbackValidationError("Invalid feedback message", issues);
3380
+ return normalized;
3381
+ }
3382
+ function validateMessage(value, issues) {
3383
+ if (typeof value !== "string") {
3384
+ issues.push({ path: "$.message", message: "message must be a string" });
3385
+ return void 0;
3386
+ }
3387
+ const message = value.trim();
3388
+ if (invalidMessageLength(message)) issues.push({ path: "$.message", message: "message must be between 1 and 8,000 UTF-8 bytes" });
3389
+ if (hasForbiddenMessageControl(message)) issues.push({ path: "$.message", message: "message contains a forbidden control character" });
3390
+ return hasMessageIssue(issues) ? void 0 : message;
3391
+ }
3392
+ function invalidMessageLength(message) {
3393
+ const bytes = encoder3.encode(message).byteLength;
3394
+ return bytes < 1 || bytes > FEEDBACK_MAX_MESSAGE_BYTES;
3395
+ }
3396
+ function hasForbiddenMessageControl(message) {
3397
+ for (const character of message) if (isForbiddenMessageControl(character)) return true;
3398
+ return false;
3399
+ }
3400
+ function isForbiddenMessageControl(character) {
3401
+ return isControl(character) && character !== "\n" && character !== "\r" && character !== " ";
3402
+ }
3403
+ function hasMessageIssue(issues) {
3404
+ return issues.some((issue) => issue.path === "$.message");
3405
+ }
3406
+ function validateApp(input, issues) {
3407
+ if (input === void 0) return void 0;
3408
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3409
+ issues.push({ path: "$.app", message: "app must be an object" });
3410
+ return void 0;
3411
+ }
3412
+ checkKeys(input, ["name", "version", "build"], issues, "$.app", false);
3413
+ const name = metadata(property(input, "name"), "$.app.name", issues, true);
3414
+ const version = metadata(property(input, "version"), "$.app.version", issues, false);
3415
+ const build = metadata(property(input, "build"), "$.app.build", issues, false);
3416
+ return name ? { name, ...version ? { version } : {}, ...build ? { build } : {} } : void 0;
3417
+ }
3418
+ function validateSDK(input, issues) {
3419
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3420
+ issues.push({ path: "$.sdk", message: "sdk must be an object" });
3421
+ return void 0;
3422
+ }
3423
+ checkKeys(input, ["client", "react", "react_native"], issues, "$.sdk", false);
3424
+ const client = metadata(property(input, "client"), "$.sdk.client", issues, true);
3425
+ const react = metadata(property(input, "react"), "$.sdk.react", issues, false);
3426
+ const reactNative = metadata(property(input, "react_native"), "$.sdk.react_native", issues, false);
3427
+ return client ? { client, ...react ? { react } : {}, ...reactNative ? { react_native: reactNative } : {} } : void 0;
3428
+ }
3429
+ function validatePlatform(input, issues) {
3430
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3431
+ issues.push({ path: "$.platform", message: "platform must be an object" });
3432
+ return void 0;
3433
+ }
3434
+ checkKeys(input, ["kind", "os_name", "os_version", "browser_name", "browser_version", "device_class", "device_model"], issues, "$.platform", false);
3435
+ const kind = enumValue(property(input, "kind"), ["web", "ios", "android", "macos"], "$.platform.kind", issues);
3436
+ const deviceClassInput = property(input, "device_class");
3437
+ const deviceClass = deviceClassInput === void 0 ? void 0 : enumValue(deviceClassInput, ["phone", "tablet", "desktop"], "$.platform.device_class", issues);
3438
+ const values = {
3439
+ os_name: metadata(property(input, "os_name"), "$.platform.os_name", issues, false),
3440
+ os_version: metadata(property(input, "os_version"), "$.platform.os_version", issues, false),
3441
+ browser_name: metadata(property(input, "browser_name"), "$.platform.browser_name", issues, false),
3442
+ browser_version: metadata(property(input, "browser_version"), "$.platform.browser_version", issues, false),
3443
+ device_model: metadata(property(input, "device_model"), "$.platform.device_model", issues, false)
3444
+ };
3445
+ if (!kind) return void 0;
3446
+ return buildFeedbackPlatform(kind, values, deviceClass);
2762
3447
  }
2763
- function chatDigest(bytes) {
2764
- return Array.from(sha256(new Uint8Array(bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
3448
+ function buildFeedbackPlatform(kind, values, deviceClass) {
3449
+ return {
3450
+ kind,
3451
+ ...values.os_name ? { os_name: values.os_name } : {},
3452
+ ...values.os_version ? { os_version: values.os_version } : {},
3453
+ ...values.browser_name ? { browser_name: values.browser_name } : {},
3454
+ ...values.browser_version ? { browser_version: values.browser_version } : {},
3455
+ ...deviceClass ? { device_class: deviceClass } : {},
3456
+ ...values.device_model ? { device_model: values.device_model } : {}
3457
+ };
2765
3458
  }
2766
- function chatMessageFor(message) {
2767
- return Object.freeze({ ...message, attachments: Object.freeze([...message.attachments]) });
3459
+ function validateConnection(input, issues) {
3460
+ if (input === void 0) return void 0;
3461
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3462
+ issues.push({ path: "$.connection", message: "connection must be an object" });
3463
+ return void 0;
3464
+ }
3465
+ checkKeys(input, ["state", "error_code"], issues, "$.connection", false);
3466
+ const state = safeToken(property(input, "state"), "$.connection.state", issues, true);
3467
+ const errorCode = safeToken(property(input, "error_code"), "$.connection.error_code", issues, false);
3468
+ return state ? { state, ...errorCode ? { error_code: errorCode } : {} } : void 0;
2768
3469
  }
2769
- function chatReceiptFor(receipt) {
2770
- return Object.freeze({ ...receipt });
3470
+ function validateScope(input, issues) {
3471
+ if (input === void 0) return void 0;
3472
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3473
+ issues.push({ path: "$.scope", message: "scope must be an object" });
3474
+ return void 0;
3475
+ }
3476
+ checkKeys(input, ["space_id", "episode_id", "participant_id"], issues, "$.scope", false);
3477
+ return buildFeedbackScope(optionalUUID(property(input, "space_id"), "$.scope.space_id", issues), optionalUUID(property(input, "episode_id"), "$.scope.episode_id", issues), optionalUUID(property(input, "participant_id"), "$.scope.participant_id", issues));
2771
3478
  }
2772
- function mergeChatMessage(messages, existing, incoming) {
3479
+ function buildFeedbackScope(space, episode, participant) {
3480
+ if (!space && !episode && !participant) return {};
3481
+ return {
3482
+ ...space ? { space_id: space } : {},
3483
+ ...episode ? { episode_id: episode } : {},
3484
+ ...participant ? { participant_id: participant } : {}
3485
+ };
3486
+ }
3487
+ function validateCorrelations(input, issues) {
3488
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3489
+ issues.push({ path: "$.correlations", message: "correlations must be an object" });
3490
+ return void 0;
3491
+ }
3492
+ checkKeys(input, ["journey_id", "root_journey_id", "trace_id", "span_id", "request_id", "command_id", "diagnostic_reference"], issues, "$.correlations", false);
3493
+ const journey = optionalUUID(property(input, "journey_id"), "$.correlations.journey_id", issues);
3494
+ const root = optionalUUID(property(input, "root_journey_id"), "$.correlations.root_journey_id", issues);
3495
+ const trace = optionalHex(property(input, "trace_id"), TRACE_ID_PATTERN, "$.correlations.trace_id", issues);
3496
+ const span = optionalHex(property(input, "span_id"), SPAN_ID_PATTERN, "$.correlations.span_id", issues);
3497
+ const request = optionalUUID(property(input, "request_id"), "$.correlations.request_id", issues);
3498
+ const command = optionalUUID(property(input, "command_id"), "$.correlations.command_id", issues);
3499
+ const diagnostic = validateDiagnosticReference(property(input, "diagnostic_reference"), issues);
3500
+ return buildFeedbackCorrelations(journey, root, trace, span, request, command, diagnostic);
3501
+ }
3502
+ function validateDiagnosticReference(input, issues) {
3503
+ if (input === void 0) return void 0;
3504
+ if (typeof input !== "string" || !isDiagnosticReference(input)) {
3505
+ issues.push({ path: "$.correlations.diagnostic_reference", message: "diagnostic reference is malformed" });
3506
+ return void 0;
3507
+ }
3508
+ return input;
3509
+ }
3510
+ function buildFeedbackCorrelations(journey, root, trace, span, request, command, diagnostic) {
3511
+ return {
3512
+ ...journey ? { journey_id: journey } : {},
3513
+ ...root ? { root_journey_id: root } : {},
3514
+ ...trace ? { trace_id: trace } : {},
3515
+ ...span ? { span_id: span } : {},
3516
+ ...request ? { request_id: request } : {},
3517
+ ...command ? { command_id: command } : {},
3518
+ ...diagnostic ? { diagnostic_reference: diagnostic } : {}
3519
+ };
3520
+ }
3521
+ function validateDiagnostics(input, issues) {
3522
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3523
+ issues.push({ path: "$.diagnostics", message: "diagnostics must be an object" });
3524
+ return void 0;
3525
+ }
3526
+ checkKeys(input, ["availability", "dropped_count", "telemetry_events", "diagnostic_events"], issues, "$.diagnostics", false);
3527
+ const availability = enumValue(property(input, "availability"), ["available", "disabled", "disposed", "unavailable"], "$.diagnostics.availability", issues);
3528
+ const dropped = boundedInteger2(property(input, "dropped_count"), 0, Number.MAX_SAFE_INTEGER, "$.diagnostics.dropped_count", issues);
3529
+ const telemetry = validateTelemetryEvents(property(input, "telemetry_events"), issues);
3530
+ const diagnostic = validateDiagnosticEvents(property(input, "diagnostic_events"), issues);
3531
+ if (!availability || dropped === void 0 || !telemetry || !diagnostic) return void 0;
3532
+ return { availability, dropped_count: dropped, telemetry_events: telemetry, diagnostic_events: diagnostic };
3533
+ }
3534
+ function validateTelemetryEvents(input, issues) {
3535
+ if (!Array.isArray(input)) {
3536
+ issues.push({ path: "$.diagnostics.telemetry_events", message: "telemetry_events must be an array" });
3537
+ return void 0;
3538
+ }
3539
+ if (input.length > FEEDBACK_MAX_TELEMETRY_EVENTS) issues.push({ path: "$.diagnostics.telemetry_events", message: "too many telemetry events" });
3540
+ const events = [];
3541
+ input.slice(0, FEEDBACK_MAX_TELEMETRY_EVENTS).forEach((event, index) => {
3542
+ const parsed = sanitizeTelemetryEvent(event, `$.diagnostics.telemetry_events[${index}]`, issues);
3543
+ if (parsed) events.push(parsed);
3544
+ });
3545
+ return events;
3546
+ }
3547
+ function validateDiagnosticEvents(input, issues) {
3548
+ if (!Array.isArray(input)) {
3549
+ issues.push({ path: "$.diagnostics.diagnostic_events", message: "diagnostic_events must be an array" });
3550
+ return void 0;
3551
+ }
3552
+ if (input.length > FEEDBACK_MAX_DIAGNOSTIC_EVENTS) issues.push({ path: "$.diagnostics.diagnostic_events", message: "too many diagnostic events" });
3553
+ const events = [];
3554
+ input.slice(0, FEEDBACK_MAX_DIAGNOSTIC_EVENTS).forEach((event, index) => {
3555
+ if (typeof event !== "object" || event === null || Array.isArray(event)) {
3556
+ issues.push({ path: `$.diagnostics.diagnostic_events[${index}]`, message: "diagnostic event must be an object" });
3557
+ return;
3558
+ }
3559
+ const attributes = redactDiagnosticAttributes(property(event, "attributes")).attributes;
3560
+ const sanitized = { ...event, ...Object.keys(attributes).length > 0 ? { attributes } : {} };
3561
+ try {
3562
+ events.push(parseDiagnosticEventDraft(sanitized));
3563
+ } catch {
3564
+ issues.push({ path: `$.diagnostics.diagnostic_events[${index}]`, message: "diagnostic event failed the diagnostics contract" });
3565
+ }
3566
+ });
3567
+ return events;
3568
+ }
3569
+ function sanitizeTelemetryEvent(input, path, issues) {
3570
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3571
+ issues.push({ path, message: "telemetry event must be an object" });
3572
+ return void 0;
3573
+ }
3574
+ checkKeys(input, TELEMETRY_KEYS, issues, path, false);
3575
+ const identity = validateTelemetryEventIdentity(input, path, issues);
3576
+ const shape = validateTelemetryEventShape(input, path, issues);
3577
+ if (!identity || !shape) return void 0;
3578
+ const optional = validateTelemetryEventOptionalFields(input, path, issues);
3579
+ return buildSanitizedTelemetryEvent(identity, shape, optional);
3580
+ }
3581
+ function buildSanitizedTelemetryEvent(identity, shape, optional) {
3582
+ const event = {
3583
+ version: 1,
3584
+ event_id: identity.eventId,
3585
+ journey_id: identity.journeyId,
3586
+ sequence: identity.sequence,
3587
+ occurred_at: identity.occurredAt,
3588
+ name: shape.name,
3589
+ phase: shape.phase,
3590
+ state: shape.state,
3591
+ origin_kind: shape.origin,
3592
+ first_observed_layer: shape.firstObserved,
3593
+ upstream_visibility: shape.visibility,
3594
+ ...optional.parentEvent ? { parent_event_id: optional.parentEvent } : {},
3595
+ ...optional.traceId ? { trace_id: optional.traceId } : {},
3596
+ ...optional.spanId ? { span_id: optional.spanId } : {},
3597
+ ...optional.traceparent ? { traceparent: optional.traceparent } : {},
3598
+ ...optional.tracestate ? { tracestate: optional.tracestate } : {},
3599
+ ...optional.attributes ? { attributes: optional.attributes } : {}
3600
+ };
3601
+ return isSanitizedTelemetryEvent(event) ? event : void 0;
3602
+ }
3603
+ function validateTelemetryEventIdentity(input, path, issues) {
3604
+ const version = property(input, "version") === 1;
3605
+ if (!version) issues.push({ path: `${path}.version`, message: "unsupported telemetry version" });
3606
+ const eventId = requiredUUID(property(input, "event_id"), `${path}.event_id`, issues);
3607
+ const journeyId = requiredUUID(property(input, "journey_id"), `${path}.journey_id`, issues);
3608
+ const sequence = boundedInteger2(property(input, "sequence"), 0, Number.MAX_SAFE_INTEGER, `${path}.sequence`, issues);
3609
+ const occurredAt = checkDateTimeValue(property(input, "occurred_at"), `${path}.occurred_at`, issues);
3610
+ if (!version || !eventId || !journeyId || sequence === void 0 || !occurredAt) return void 0;
3611
+ return { eventId, journeyId, sequence, occurredAt };
3612
+ }
3613
+ function validateTelemetryEventShape(input, path, issues) {
3614
+ const name = enumValue(property(input, "name"), TELEMETRY_NAMES, `${path}.name`, issues);
3615
+ const phase = enumValue(property(input, "phase"), TELEMETRY_PHASES, `${path}.phase`, issues);
3616
+ const state = enumValue(property(input, "state"), TELEMETRY_STATES, `${path}.state`, issues);
3617
+ const origin = enumValue(property(input, "origin_kind"), TELEMETRY_ORIGINS, `${path}.origin_kind`, issues);
3618
+ const firstObserved = enumValue(property(input, "first_observed_layer"), TELEMETRY_ORIGINS, `${path}.first_observed_layer`, issues);
3619
+ const visibility = enumValue(property(input, "upstream_visibility"), TELEMETRY_VISIBILITY, `${path}.upstream_visibility`, issues);
3620
+ if (!name || !phase || !state || !origin || !firstObserved || !visibility) return void 0;
3621
+ return { name, phase, state, origin, firstObserved, visibility };
3622
+ }
3623
+ function validateTelemetryEventOptionalFields(input, path, issues) {
3624
+ return {
3625
+ parentEvent: optionalUUID(property(input, "parent_event_id"), `${path}.parent_event_id`, issues),
3626
+ traceId: optionalHex(property(input, "trace_id"), TRACE_ID_PATTERN, `${path}.trace_id`, issues),
3627
+ spanId: optionalHex(property(input, "span_id"), SPAN_ID_PATTERN, `${path}.span_id`, issues),
3628
+ traceparent: optionalTraceparent(property(input, "traceparent"), `${path}.traceparent`, issues),
3629
+ tracestate: optionalSafeText(property(input, "tracestate"), `${path}.tracestate`, 512, issues),
3630
+ attributes: sanitizeTelemetryAttributes(property(input, "attributes"), `${path}.attributes`, issues)
3631
+ };
3632
+ }
3633
+ function isSanitizedTelemetryEvent(value) {
3634
+ switch (value.name) {
3635
+ case "sync.frame":
3636
+ return value.origin_kind === "sync";
3637
+ case "http.request":
3638
+ return value.origin_kind === "http";
3639
+ case "rtc.summary":
3640
+ return value.origin_kind === "rtc";
3641
+ case "diagnostic.timeline":
3642
+ return value.origin_kind === "diagnostic";
3643
+ case "journey.started":
3644
+ return isJourneyStartedEvent(value);
3645
+ case "journey.phase":
3646
+ return isJourneyPhaseEvent(value);
3647
+ case "journey.terminal":
3648
+ return isJourneyTerminalEvent(value);
3649
+ case "journey.linked":
3650
+ return isJourneyLinkedEvent(value);
3651
+ }
3652
+ }
3653
+ function isJourneyStartedEvent(value) {
3654
+ return value.origin_kind === "client" && value.phase === "root" && value.state === "started";
3655
+ }
3656
+ function isJourneyPhaseEvent(value) {
3657
+ return value.origin_kind === "client" && value.phase !== "root" && value.phase !== "terminal" && (value.state === "in_progress" || value.state === "observed");
3658
+ }
3659
+ function isJourneyTerminalEvent(value) {
3660
+ return value.origin_kind === "client" && value.phase === "terminal" && (value.state === "succeeded" || value.state === "failed" || value.state === "cancelled");
3661
+ }
3662
+ function isJourneyLinkedEvent(value) {
3663
+ return value.origin_kind === "client" && value.phase === "root" && value.state === "observed" && typeof value.parent_event_id === "string";
3664
+ }
3665
+ function sanitizeTelemetryAttributes(input, path, issues) {
3666
+ if (input === void 0) return void 0;
3667
+ if (!isTelemetryAttributeObject(input)) {
3668
+ issues.push({ path, message: "telemetry attributes are not safe" });
3669
+ return void 0;
3670
+ }
3671
+ const redacted = redactDiagnosticAttributes(input).attributes;
3672
+ const normalized = normalizeTelemetryAttributes(redacted);
3673
+ if (!normalized) return void 0;
3674
+ for (const [key, value] of Object.entries(normalized)) if (typeof value === "string" && (value.length > 256 || isControlText(value) || UNSAFE_VALUE_PATTERN.test(value))) issues.push({ path: `${path}.${key}`, message: "telemetry attribute value is unsafe" });
3675
+ return normalized;
3676
+ }
3677
+ function isTelemetryAttributeObject(value) {
3678
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
3679
+ return Object.keys(value).every((key) => {
3680
+ const entry = property(value, key);
3681
+ return typeof entry === "boolean" || typeof entry === "string" || typeof entry === "number" && Number.isFinite(entry);
3682
+ });
3683
+ }
3684
+ function validateLocalState(input, issues) {
3685
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3686
+ issues.push({ path: "$.local_state", message: "local_state must be an object" });
3687
+ return void 0;
3688
+ }
3689
+ checkKeys(input, ["registry_version", "entries"], issues, "$.local_state", false);
3690
+ if (property(input, "registry_version") !== FEEDBACK_LOCAL_STATE_REGISTRY_VERSION) issues.push({ path: "$.local_state.registry_version", message: "unsupported local state registry" });
3691
+ const entriesInput = property(input, "entries");
3692
+ if (!Array.isArray(entriesInput)) {
3693
+ issues.push({ path: "$.local_state.entries", message: "local state entries must be an array" });
3694
+ return void 0;
3695
+ }
3696
+ if (entriesInput.length > FEEDBACK_MAX_LOCAL_STATE_ENTRIES) issues.push({ path: "$.local_state.entries", message: "too many local state entries" });
3697
+ const entries = [];
3698
+ const seen = /* @__PURE__ */ new Set();
3699
+ entriesInput.slice(0, FEEDBACK_MAX_LOCAL_STATE_ENTRIES).forEach((entry, index) => {
3700
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
3701
+ issues.push({ path: `$.local_state.entries[${index}]`, message: "local state entry must be an object" });
3702
+ return;
3703
+ }
3704
+ checkKeys(entry, ["key", "value"], issues, `$.local_state.entries[${index}]`, false);
3705
+ const key = property(entry, "key");
3706
+ const entryValue = property(entry, "value");
3707
+ if (typeof key !== "string" || key.length === 0 || seen.has(key)) {
3708
+ issues.push({ path: `$.local_state.entries[${index}].key`, message: "local state key is invalid or duplicated" });
3709
+ return;
3710
+ }
3711
+ seen.add(key);
3712
+ if (!validLocalStateEntry(key, entryValue)) issues.push({ path: `$.local_state.entries[${index}]`, message: "local state entry is not allowlisted" });
3713
+ else if (isFeedbackLocalStateValue(entryValue)) entries.push({ key, value: entryValue });
3714
+ });
3715
+ return { registry_version: FEEDBACK_LOCAL_STATE_REGISTRY_VERSION, entries };
3716
+ }
3717
+ function validateCookies(input, issues) {
3718
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3719
+ issues.push({ path: "$.cookies", message: "cookies must be an object" });
3720
+ return void 0;
3721
+ }
3722
+ checkKeys(input, ["registry_version", "entries"], issues, "$.cookies", false);
3723
+ if (property(input, "registry_version") !== FEEDBACK_COOKIE_REGISTRY_VERSION) issues.push({ path: "$.cookies.registry_version", message: "unsupported cookie registry" });
3724
+ const entriesInput = property(input, "entries");
3725
+ if (!Array.isArray(entriesInput)) {
3726
+ issues.push({ path: "$.cookies.entries", message: "cookie entries must be an array" });
3727
+ return void 0;
3728
+ }
3729
+ if (entriesInput.length > FEEDBACK_MAX_COOKIE_ENTRIES) issues.push({ path: "$.cookies.entries", message: "too many cookie entries" });
3730
+ const entries = [];
3731
+ const seen = /* @__PURE__ */ new Set();
3732
+ for (const [index, entry] of entriesInput.slice(0, FEEDBACK_MAX_COOKIE_ENTRIES).entries()) {
3733
+ const parsed = validateCookieEntry(entry, index, seen, issues);
3734
+ if (parsed) entries.push(parsed);
3735
+ }
3736
+ return { registry_version: FEEDBACK_COOKIE_REGISTRY_VERSION, entries };
3737
+ }
3738
+ function validateCookieEntry(input, index, seen, issues) {
3739
+ const path = `$.cookies.entries[${index}]`;
3740
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3741
+ issues.push({ path, message: "cookie entry must be an object" });
3742
+ return void 0;
3743
+ }
3744
+ checkKeys(input, ["name", "present", "value"], issues, path, false);
3745
+ const name = enumValue(property(input, "name"), ["chalk_theme", "chalk_sidebar_state", "account", "csrf"], `${path}.name`, issues);
3746
+ const present = property(input, "present");
3747
+ if (!name || typeof present !== "boolean" || seen.has(name)) {
3748
+ issues.push({ path, message: "cookie entry is invalid or duplicated" });
3749
+ return void 0;
3750
+ }
3751
+ seen.add(name);
3752
+ return buildCookieEntry(name, present, property(input, "value"), path, issues);
3753
+ }
3754
+ function buildCookieEntry(name, present, input, path, issues) {
3755
+ const value = cookieValue(name, input, `${path}.value`, issues);
3756
+ if (!present && value !== void 0) issues.push({ path: `${path}.value`, message: "absent cookies cannot include values" });
3757
+ return { name, present, ...value !== void 0 ? { value } : {} };
3758
+ }
3759
+ function cookieValue(name, input, path, issues) {
3760
+ if (input === void 0) return void 0;
3761
+ if (typeof input !== "string") {
3762
+ issues.push({ path, message: "cookie value is invalid" });
3763
+ return void 0;
3764
+ }
3765
+ if (name === "account" || name === "csrf") {
3766
+ issues.push({ path, message: "account and CSRF cookie values are never collected" });
3767
+ return void 0;
3768
+ }
3769
+ if (name === "chalk_theme") {
3770
+ const value2 = ["light", "dark", "system"];
3771
+ const match2 = value2.find((candidate) => candidate === input);
3772
+ if (match2 !== void 0) return match2;
3773
+ issues.push({ path, message: "theme cookie value is invalid" });
3774
+ return void 0;
3775
+ }
3776
+ const value = ["true", "false"];
3777
+ const match = value.find((candidate) => candidate === input);
3778
+ if (match !== void 0) return match;
3779
+ issues.push({ path, message: "sidebar cookie value is invalid" });
3780
+ return void 0;
3781
+ }
3782
+ function validateScreenshotState(input, issues) {
3783
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
3784
+ issues.push({ path: "$.screenshot", message: "screenshot state must be an object" });
3785
+ return void 0;
3786
+ }
3787
+ checkKeys(input, ["state", "captured_at", "failure_code"], issues, "$.screenshot", false);
3788
+ const state = enumValue(property(input, "state"), ["captured", "partial", "removed", "unavailable"], "$.screenshot.state", issues);
3789
+ const failureInput = property(input, "failure_code");
3790
+ const failure3 = failureInput === void 0 ? void 0 : enumValue(failureInput, ["capture_failed", "unsupported", "tainted", "secure_surface", "too_large"], "$.screenshot.failure_code", issues);
3791
+ const capturedAtInput = property(input, "captured_at");
3792
+ const capturedAt = capturedAtInput === void 0 ? void 0 : checkDateTimeValue(capturedAtInput, "$.screenshot.captured_at", issues);
3793
+ validateScreenshotStateConsistency(state, capturedAt, failure3, issues);
3794
+ if (!state) return void 0;
3795
+ return { state, ...capturedAt ? { captured_at: capturedAt } : {}, ...failure3 ? { failure_code: failure3 } : {} };
3796
+ }
3797
+ function validateScreenshotStateConsistency(state, capturedAt, failure3, issues) {
3798
+ const captured = state === "captured" || state === "partial";
3799
+ if (captured && !capturedAt) issues.push({ path: "$.screenshot.captured_at", message: "captured screenshots require a timestamp" });
3800
+ if (captured && failure3) issues.push({ path: "$.screenshot.failure_code", message: "captured screenshots cannot have a failure code" });
3801
+ }
3802
+ function validLocalStateEntry(key, value) {
3803
+ if (key === "chalk.tenant-hint") return isUUID(value);
3804
+ if (key === "chalk.web.telemetry.v1" || key === "chalk.mobile.telemetry.v1") return validTelemetrySummary(value);
3805
+ if (key.startsWith("chalk.dashboard-request.")) return DASHBOARD_ACTION_PATTERN.test(key.slice("chalk.dashboard-request.".length)) && value === true;
3806
+ return false;
3807
+ }
3808
+ function isFeedbackLocalStateValue(value) {
3809
+ if (typeof value === "string" || typeof value === "boolean") return true;
3810
+ if (typeof value === "number") return Number.isFinite(value);
3811
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).every((key) => {
3812
+ const entry = property(value, key);
3813
+ return typeof entry === "number" && Number.isFinite(entry);
3814
+ });
3815
+ }
3816
+ function validTelemetrySummary(value) {
3817
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
3818
+ const keys = ["pending_count", "timeline_count", "dropped_count"];
3819
+ if (!keys.some((key) => property(value, key) !== void 0)) return false;
3820
+ return keys.every((key) => {
3821
+ const entry = property(value, key);
3822
+ return entry === void 0 || typeof entry === "number" && Number.isSafeInteger(entry) && entry >= 0 && entry <= 500;
3823
+ });
3824
+ }
3825
+ function metadata(value, path, issues, required) {
3826
+ if (value === void 0 && !required) return void 0;
3827
+ if (typeof value !== "string" || !isSafeMetadata(value)) {
3828
+ issues.push({ path, message: required ? "metadata is required and unsafe" : "metadata is unsafe" });
3829
+ return void 0;
3830
+ }
3831
+ return value;
3832
+ }
3833
+ function isSafeMetadata(value) {
3834
+ return value.length > 0 && value.length <= 128 && value.trim() === value && !isControlText(value) && !UNSAFE_VALUE_PATTERN.test(value);
3835
+ }
3836
+ function safeToken(value, path, issues, required) {
3837
+ if (value === void 0 && !required) return void 0;
3838
+ if (typeof value !== "string" || value.length === 0 || value.length > 128 || !SAFE_TOKEN_PATTERN.test(value) || UNSAFE_VALUE_PATTERN.test(value)) {
3839
+ issues.push({ path, message: required ? "token is required and unsafe" : "token is unsafe" });
3840
+ return void 0;
3841
+ }
3842
+ return value;
3843
+ }
3844
+ function optionalUUID(value, path, issues) {
3845
+ if (value === void 0) return void 0;
3846
+ return requiredUUID(value, path, issues);
3847
+ }
3848
+ function requiredUUID(value, path, issues) {
3849
+ if (typeof value !== "string" || !isUUID(value)) {
3850
+ issues.push({ path, message: "expected a non-zero UUID" });
3851
+ return void 0;
3852
+ }
3853
+ return value;
3854
+ }
3855
+ function optionalHex(value, pattern, path, issues) {
3856
+ if (value === void 0) return void 0;
3857
+ if (typeof value !== "string" || !pattern.test(value.toLowerCase()) || value !== value.toLowerCase()) {
3858
+ issues.push({ path, message: "expected a lower-case non-zero hexadecimal ID" });
3859
+ return void 0;
3860
+ }
3861
+ return value;
3862
+ }
3863
+ function optionalTraceparent(value, path, issues) {
3864
+ if (value === void 0) return void 0;
3865
+ if (typeof value !== "string" || !TRACEPARENT_PATTERN.test(value)) {
3866
+ issues.push({ path, message: "traceparent is malformed" });
3867
+ return void 0;
3868
+ }
3869
+ return value;
3870
+ }
3871
+ function optionalSafeText(value, path, length, issues) {
3872
+ if (value === void 0) return void 0;
3873
+ if (typeof value !== "string" || value.length > length || isControlText(value) || UNSAFE_VALUE_PATTERN.test(value)) {
3874
+ issues.push({ path, message: "text is unsafe" });
3875
+ return void 0;
3876
+ }
3877
+ return value;
3878
+ }
3879
+ function checkDateTime(value, path, issues) {
3880
+ checkDateTimeValue(value, path, issues);
3881
+ }
3882
+ function checkDateTimeValue(value, path, issues) {
3883
+ if (typeof value !== "string" || value.length > 64 || !Number.isFinite(Date.parse(value))) {
3884
+ issues.push({ path, message: "expected an ISO date-time" });
3885
+ return void 0;
3886
+ }
3887
+ return value;
3888
+ }
3889
+ function boundedInteger2(value, minimum, maximum, path, issues) {
3890
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
3891
+ issues.push({ path, message: "expected a bounded integer" });
3892
+ return void 0;
3893
+ }
3894
+ return value;
3895
+ }
3896
+ function enumValue(value, values, path, issues) {
3897
+ const match = values.find((candidate) => candidate === value);
3898
+ if (match !== void 0) return match;
3899
+ issues.push({ path, message: `expected one of: ${values.join(", ")}` });
3900
+ return void 0;
3901
+ }
3902
+ function checkKeys(value, allowed, issues, path, allowNullable) {
3903
+ const allowedSet = new Set(allowed);
3904
+ for (const key of Object.keys(value)) if (!allowedSet.has(key) && !(allowNullable && key === "value")) issues.push({ path: `${path}.${key}`, message: "unknown property" });
3905
+ }
3906
+ function failure2(path, message) {
3907
+ return { ok: false, issues: [{ path, message }] };
3908
+ }
3909
+ function property(value, key) {
3910
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
3911
+ return Object.prototype.hasOwnProperty.call(value, key) ? Reflect.get(value, key) : void 0;
3912
+ }
3913
+ function isUUID(value) {
3914
+ return typeof value === "string" && UUID_PATTERN.test(value) && value.replaceAll("0", "").replaceAll("-", "") !== "";
3915
+ }
3916
+ function validBase64(value) {
3917
+ return value.length > 0 && value.length % 4 === 0 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value);
3918
+ }
3919
+ function base64ByteLength(value) {
3920
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
3921
+ return Math.floor(value.length * 3 / 4) - padding;
3922
+ }
3923
+ function encodedByteLength(value) {
3924
+ return encoder3.encode(JSON.stringify(value)).byteLength;
3925
+ }
3926
+ function isControl(character) {
3927
+ return /\p{Cc}/u.test(character);
3928
+ }
3929
+ function isControlText(value) {
3930
+ for (const character of value) if (isControl(character)) return true;
3931
+ return false;
3932
+ }
3933
+
3934
+ // src/feedback/collector.ts
3935
+ import { validateDiagnosticEventDraft } from "@q9labsai/diagnostics-contracts";
3936
+
3937
+ // src/feedback/context.ts
3938
+ function safeSubject(snapshot) {
3939
+ const subject = snapshot.subject;
3940
+ if (!subject) return void 0;
3941
+ return {
3942
+ tenant_id: subject.tenantId,
3943
+ space_id: subject.spaceId,
3944
+ episode_id: subject.episodeId,
3945
+ participant_id: subject.participantId,
3946
+ participant_generation: subject.participantGeneration
3947
+ };
3948
+ }
3949
+
3950
+ // src/feedback/collector.ts
3951
+ function collectFeedbackEvidence(input, context = {}) {
3952
+ const collectedAt = new Date((context.now ?? Date.now)()).toISOString();
3953
+ const evidence = {
3954
+ schema_version: FEEDBACK_EVIDENCE_SCHEMA_VERSION,
3955
+ collected_at: collectedAt,
3956
+ ...input.app ? { app: input.app } : {},
3957
+ sdk: input.sdk,
3958
+ platform: input.platform,
3959
+ ...context.connection ? { connection: context.connection } : {},
3960
+ ...context.scope ? { scope: context.scope } : {},
3961
+ correlations: context.correlations ?? {},
3962
+ diagnostics: context.diagnostics ?? unavailableDiagnostics(),
3963
+ local_state: { registry_version: FEEDBACK_LOCAL_STATE_REGISTRY_VERSION, entries: collectLocalState(input.local_state) },
3964
+ cookies: { registry_version: FEEDBACK_COOKIE_REGISTRY_VERSION, entries: collectCookies(input.cookies) },
3965
+ screenshot: screenshotState(input.screenshot)
3966
+ };
3967
+ return parseFeedbackEvidence(evidence);
3968
+ }
3969
+ function collectFeedbackEvidenceFromContext(input, context) {
3970
+ const snapshot = context.connection();
3971
+ const subject = safeSubject(snapshot);
3972
+ const activeTelemetry = context.telemetry;
3973
+ const activeDiagnostics = context.diagnosticContext();
3974
+ const correlationContext = activeTelemetry ?? activeDiagnostics;
3975
+ const trace = parseTraceparent(correlationContext?.traceparent);
3976
+ const diagnostics = context.diagnosticSnapshot();
3977
+ const availability = context.diagnosticAvailability();
3978
+ return collectFeedbackEvidence(input, {
3979
+ now: context.now,
3980
+ connection: {
3981
+ state: snapshot.state,
3982
+ ...snapshot.failure?.code ? { error_code: snapshot.failure.code } : {}
3983
+ },
3984
+ ...subject ? {
3985
+ scope: {
3986
+ space_id: subject.space_id,
3987
+ episode_id: subject.episode_id,
3988
+ participant_id: subject.participant_id
3989
+ }
3990
+ } : {},
3991
+ correlations: {
3992
+ ...correlationContext ? { journey_id: correlationContext.journeyId } : {},
3993
+ ...activeTelemetry ? { root_journey_id: activeTelemetry.rootJourneyId } : {},
3994
+ ...trace ? { trace_id: trace.traceId, span_id: trace.spanId } : {}
3995
+ },
3996
+ diagnostics: {
3997
+ availability,
3998
+ dropped_count: diagnostics.dropped,
3999
+ telemetry_events: input.local_state?.telemetry?.events ?? [],
4000
+ diagnostic_events: diagnostics.events.filter(isDiagnosticEvent)
4001
+ }
4002
+ });
4003
+ }
4004
+ function collectLocalState(input) {
4005
+ if (!input) return [];
4006
+ const entries = [];
4007
+ if (input.telemetry) {
4008
+ const summary = telemetrySummary(input.telemetry);
4009
+ if (summary) entries.push({ key: input.telemetry.storage_key ?? "chalk.web.telemetry.v1", value: summary });
4010
+ }
4011
+ if (input.tenant_hint && validUUID(input.tenant_hint)) entries.push({ key: "chalk.tenant-hint", value: input.tenant_hint });
4012
+ for (const request of input.dashboard_requests ?? []) {
4013
+ if (!request.pending || !/^[a-z][a-z0-9_-]{0,63}$/u.test(request.action)) continue;
4014
+ entries.push({ key: `chalk.dashboard-request.${request.action}`, value: true });
4015
+ }
4016
+ return entries.slice(0, 32);
4017
+ }
4018
+ function collectCookies(input) {
4019
+ if (!input) return [];
4020
+ const entries = [];
4021
+ if (input.theme !== void 0) entries.push({ name: "chalk_theme", present: true, value: input.theme });
4022
+ if (input.sidebar_state !== void 0) entries.push({ name: "chalk_sidebar_state", present: true, value: input.sidebar_state ? "true" : "false" });
4023
+ if (input.account_present !== void 0) entries.push({ name: "account", present: input.account_present });
4024
+ if (input.csrf_present !== void 0) entries.push({ name: "csrf", present: input.csrf_present });
4025
+ return entries;
4026
+ }
4027
+ function telemetrySummary(input) {
4028
+ const summary = {
4029
+ ...input.pending_count !== void 0 && boundedCount(input.pending_count) ? { pending_count: input.pending_count } : {},
4030
+ ...input.timeline_count !== void 0 && boundedCount(input.timeline_count) ? { timeline_count: input.timeline_count } : {},
4031
+ ...input.dropped_count !== void 0 && boundedCount(input.dropped_count) ? { dropped_count: input.dropped_count } : {}
4032
+ };
4033
+ return Object.keys(summary).length > 0 ? summary : void 0;
4034
+ }
4035
+ function boundedCount(value) {
4036
+ return Number.isSafeInteger(value) && value >= 0 && value <= 500;
4037
+ }
4038
+ function screenshotState(input) {
4039
+ if (!input) return { state: "unavailable", failure_code: "unsupported" };
4040
+ if (input.state === "captured" || input.state === "partial") return { state: input.state, captured_at: input.captured_at };
4041
+ return "failure_code" in input && input.failure_code ? { state: input.state, failure_code: input.failure_code } : { state: input.state };
4042
+ }
4043
+ function unavailableDiagnostics() {
4044
+ return { availability: "unavailable", dropped_count: 0, telemetry_events: [], diagnostic_events: [] };
4045
+ }
4046
+ function isDiagnosticEvent(value) {
4047
+ return validateDiagnosticEventDraft(value).ok;
4048
+ }
4049
+ function validUUID(value) {
4050
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value) && value.replaceAll("0", "").replaceAll("-", "") !== "";
4051
+ }
4052
+
4053
+ // src/feedback/transport.ts
4054
+ var FeedbackTransportError = class extends Error {
4055
+ code;
4056
+ status;
4057
+ recoverable;
4058
+ constructor(input) {
4059
+ super(input.message);
4060
+ this.name = "FeedbackTransportError";
4061
+ this.code = input.code;
4062
+ this.recoverable = input.recoverable;
4063
+ this.status = input.status;
4064
+ }
4065
+ };
4066
+ function createFeedbackTransport(options) {
4067
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
4068
+ const baseUrl = options.baseUrl.replace(/\/+$/u, "");
4069
+ return {
4070
+ send: async ({ request, idempotencyKey }) => {
4071
+ const parsedRequest = parseFeedbackReportRequest(request);
4072
+ validateTransportBounds(parsedRequest);
4073
+ validateIdempotencyKey(idempotencyKey);
4074
+ const credential = options.credential();
4075
+ if (!credential) throw new FeedbackTransportError({ code: "unauthenticated", recoverable: false, message: "Feedback authentication is unavailable.", status: 401 });
4076
+ const headers = feedbackHeaders(idempotencyKey, credential.token, options.telemetry?.());
4077
+ const response = await sendFeedbackRequest(fetchImplementation, `${baseUrl}/v1/feedback-reports`, headers, parsedRequest);
4078
+ return parseFeedbackResponse(response);
4079
+ }
4080
+ };
4081
+ }
4082
+ function validateIdempotencyKey(idempotencyKey) {
4083
+ if (idempotencyKey.length < 16 || idempotencyKey.length > 128 || !/^[A-Za-z0-9_-]+$/u.test(idempotencyKey)) throw new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback idempotency key is invalid.", status: 400 });
4084
+ }
4085
+ function feedbackHeaders(idempotencyKey, credentialToken, telemetry) {
4086
+ const headers = new Headers({ "content-type": "application/json", authorization: `Bearer ${credentialToken}`, "idempotency-key": idempotencyKey });
4087
+ if (!telemetry) return headers;
4088
+ for (const [key, value] of Object.entries(journeyHeaders(telemetry))) headers.set(key, value);
4089
+ return headers;
4090
+ }
4091
+ async function sendFeedbackRequest(fetchImplementation, url, headers, request) {
4092
+ try {
4093
+ return await fetchImplementation(url, { method: "POST", headers, body: JSON.stringify(request) });
4094
+ } catch {
4095
+ throw new FeedbackTransportError({ code: "unavailable", recoverable: true, message: "Feedback service is unavailable." });
4096
+ }
4097
+ }
4098
+ async function parseFeedbackResponse(response) {
4099
+ if (!response.ok) throw responseError3(response.status);
4100
+ let body;
4101
+ try {
4102
+ body = await response.json();
4103
+ } catch {
4104
+ throw new FeedbackTransportError({ code: "invalid_response", recoverable: true, message: "Feedback service returned an invalid receipt.", status: response.status });
4105
+ }
4106
+ try {
4107
+ return assertFeedbackReceipt(body);
4108
+ } catch {
4109
+ throw new FeedbackTransportError({ code: "invalid_response", recoverable: true, message: "Feedback service returned an invalid receipt.", status: response.status });
4110
+ }
4111
+ }
4112
+ function validateTransportBounds(request) {
4113
+ const evidenceBytes = new TextEncoder().encode(JSON.stringify(request.evidence)).byteLength;
4114
+ const requestBytes = new TextEncoder().encode(JSON.stringify(request)).byteLength;
4115
+ if (evidenceBytes > FEEDBACK_MAX_EVIDENCE_BYTES) throw new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback evidence exceeds the size limit." });
4116
+ if (requestBytes > FEEDBACK_MAX_REQUEST_BYTES) throw new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback request exceeds the size limit." });
4117
+ }
4118
+ function responseError3(status) {
4119
+ if (status === 400) return new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback request was rejected.", status });
4120
+ if (status === 413) return new FeedbackTransportError({ code: "invalid_request", recoverable: false, message: "Feedback request exceeds the size limit.", status });
4121
+ if (status === 401) return new FeedbackTransportError({ code: "unauthenticated", recoverable: false, message: "Feedback authentication was rejected.", status });
4122
+ if (status === 403) return new FeedbackTransportError({ code: "forbidden", recoverable: false, message: "Feedback submission is not allowed.", status });
4123
+ if (status === 409) return new FeedbackTransportError({ code: "conflict", recoverable: false, message: "Feedback idempotency key conflicts with another submission.", status });
4124
+ if (status === 429) return new FeedbackTransportError({ code: "rate_limited", recoverable: true, message: "Feedback submissions are temporarily limited.", status });
4125
+ return new FeedbackTransportError({ code: "unavailable", recoverable: true, message: "Feedback service is unavailable.", status });
4126
+ }
4127
+
4128
+ // src/space-client/core.ts
4129
+ import { Clock as Clock5, Context as Context13, Duration as Duration2, Effect as Effect12, Layer as Layer13 } from "effect";
4130
+
4131
+ // src/space-client/controller-effects.ts
4132
+ import { Context as Context12, Effect as Effect11, Layer as Layer12 } from "effect";
4133
+ import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
4134
+
4135
+ // src/space-client/chat-controller.ts
4136
+ import { Context as Context5, Effect as Effect4, Layer as Layer5 } from "effect";
4137
+
4138
+ // src/space-client/chat-controller-helpers.ts
4139
+ import { sha256 } from "@noble/hashes/sha2.js";
4140
+ var MAX_CHAT_PAGE_SIZE = 100;
4141
+ var MAX_LOADED_CHAT_MESSAGES = 500;
4142
+ var MAX_CHAT_TEXT_BYTES = 16384;
4143
+ var MAX_CHAT_TEXT_SCALARS = 4e3;
4144
+ var encoder4 = new TextEncoder();
4145
+ var allowedMimeTypes = new Set(CHALK_CHAT_ATTACHMENT_MIME_TYPES);
4146
+ function validateChatMessage(input) {
4147
+ const attachments = input.attachments ?? [];
4148
+ if (input.text.length === 0 && attachments.length === 0) return "A chat message requires text or an attachment";
4149
+ if (Array.from(input.text).length > MAX_CHAT_TEXT_SCALARS || encoder4.encode(input.text).byteLength > MAX_CHAT_TEXT_BYTES) return `Chat text must not exceed ${MAX_CHAT_TEXT_SCALARS} characters or ${MAX_CHAT_TEXT_BYTES} bytes`;
4150
+ if (attachments.length > CHALK_CHAT_ATTACHMENT_LIMITS.maximumPerMessage) return `A chat message supports at most ${CHALK_CHAT_ATTACHMENT_LIMITS.maximumPerMessage} attachments`;
4151
+ if (new Set(attachments.map((attachment2) => attachment2.attachmentId)).size !== attachments.length) return "Chat attachment IDs must be unique";
4152
+ return attachments.map(validateChatAttachment).find((value) => value !== null) ?? null;
4153
+ }
4154
+ function validateChatUpload(file, bytes, clientAttachmentId) {
4155
+ const fileName = "fileName" in file ? file.fileName : file.name;
4156
+ const mimeType = "mimeType" in file ? file.mimeType : file.type;
4157
+ const error2 = validateChatAttachment({ attachmentId: clientAttachmentId, fileName, mimeType, byteLength: bytes.byteLength });
4158
+ return error2 ?? { fileName, mimeType };
4159
+ }
4160
+ function chatDigest(bytes) {
4161
+ return Array.from(sha256(new Uint8Array(bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
4162
+ }
4163
+ function chatMessageFor(message) {
4164
+ return Object.freeze({ ...message, attachments: Object.freeze([...message.attachments]) });
4165
+ }
4166
+ function chatReceiptFor(receipt) {
4167
+ return Object.freeze({ ...receipt });
4168
+ }
4169
+ function mergeChatMessage(messages, existing, incoming) {
2773
4170
  if (sameChatMessage(existing, incoming)) return messages;
2774
4171
  return Object.freeze(
2775
4172
  messages.map((message) => message === existing ? incoming : message).sort((left, right) => compareChatSequence(left.sequence, right.sequence)).slice(-MAX_LOADED_CHAT_MESSAGES)
@@ -2782,16 +4179,16 @@ function compareChatSequence(left, right) {
2782
4179
  const normalizedRight = right.replace(/^0+/u, "") || "0";
2783
4180
  return normalizedLeft.length === normalizedRight.length ? normalizedLeft < normalizedRight ? -1 : 1 : normalizedLeft.length - normalizedRight.length;
2784
4181
  }
2785
- function validateChatAttachment(attachment) {
2786
- const fileNameBytes = encoder3.encode(attachment.fileName).byteLength;
2787
- if (!attachment.attachmentId || fileNameBytes < 1 || fileNameBytes > CHALK_CHAT_ATTACHMENT_LIMITS.maximumFileNameBytes) return "Chat attachment metadata is invalid";
2788
- if (!Number.isSafeInteger(attachment.byteLength) || attachment.byteLength < 1 || attachment.byteLength > CHALK_CHAT_ATTACHMENT_LIMITS.maximumByteLength) return "Chat attachment metadata is invalid";
2789
- return allowedMimeTypes.has(attachment.mimeType) ? null : "Chat attachment MIME type is not allowed";
4182
+ function validateChatAttachment(attachment2) {
4183
+ const fileNameBytes = encoder4.encode(attachment2.fileName).byteLength;
4184
+ if (!attachment2.attachmentId || fileNameBytes < 1 || fileNameBytes > CHALK_CHAT_ATTACHMENT_LIMITS.maximumFileNameBytes) return "Chat attachment metadata is invalid";
4185
+ if (!Number.isSafeInteger(attachment2.byteLength) || attachment2.byteLength < 1 || attachment2.byteLength > CHALK_CHAT_ATTACHMENT_LIMITS.maximumByteLength) return "Chat attachment metadata is invalid";
4186
+ return allowedMimeTypes.has(attachment2.mimeType) ? null : "Chat attachment MIME type is not allowed";
2790
4187
  }
2791
4188
  function sameChatMessage(left, right) {
2792
- return left.messageId === right.messageId && left.clientMessageId === right.clientMessageId && left.sequence === right.sequence && left.participantId === right.participantId && left.displayName === right.displayName && left.text === right.text && left.createdAt === right.createdAt && left.attachments.length === right.attachments.length && left.attachments.every((attachment, index) => {
4189
+ return left.messageId === right.messageId && left.clientMessageId === right.clientMessageId && left.sequence === right.sequence && left.participantId === right.participantId && left.displayName === right.displayName && left.text === right.text && left.createdAt === right.createdAt && left.attachments.length === right.attachments.length && left.attachments.every((attachment2, index) => {
2793
4190
  const candidate = right.attachments[index];
2794
- return candidate?.attachmentId === attachment.attachmentId && candidate.fileName === attachment.fileName && candidate.mimeType === attachment.mimeType && candidate.byteLength === attachment.byteLength;
4191
+ return candidate?.attachmentId === attachment2.attachmentId && candidate.fileName === attachment2.fileName && candidate.mimeType === attachment2.mimeType && candidate.byteLength === attachment2.byteLength;
2795
4192
  });
2796
4193
  }
2797
4194
 
@@ -2807,7 +4204,7 @@ var makeChatController = (input) => Effect4.gen(function* () {
2807
4204
  const controller = yield* Effect4.sync(() => {
2808
4205
  let instance = null;
2809
4206
  const transport = input.createTransport?.({ token: () => Effect4.runPromiseWith(context)(input.connection.getSyncToken()) }) ?? null;
2810
- instance = new ChatControllerRuntime(input.connection, input.store, transport, input.apiBaseUrl ?? "https://api.chalkmeet.com", input.fetch ?? globalThis.fetch, fork, input.episodeDiagnostics);
4207
+ instance = new ChatControllerRuntime(input.connection, input.store, transport, input.apiBaseUrl ?? "https://api.chalkmeet.com", input.fetch ?? globalThis.fetch, fork, input.episodeDiagnostics, input.offline);
2811
4208
  return instance;
2812
4209
  });
2813
4210
  yield* Effect4.addFinalizer(() => Effect4.sync(() => controller.dispose()));
@@ -2821,8 +4218,10 @@ var ChatControllerRuntime = class {
2821
4218
  #fetch;
2822
4219
  #fork;
2823
4220
  #diagnostics;
4221
+ #offline;
2824
4222
  #catchUpRunning = false;
2825
4223
  #catchUpRequested = false;
4224
+ #contiguousSequence = null;
2826
4225
  #hasOlder = false;
2827
4226
  #historyTruncated = false;
2828
4227
  #initialCatchUpComplete = true;
@@ -2838,7 +4237,8 @@ var ChatControllerRuntime = class {
2838
4237
  #unsubscribeConnection = null;
2839
4238
  #unsubscribeEvents = null;
2840
4239
  #unsubscribeSnapshot = null;
2841
- constructor(connection, store, transport, apiBaseUrl, fetch, fork, diagnostics) {
4240
+ constructor(connection, store, transport, apiBaseUrl, fetch, fork, diagnostics, offline) {
4241
+ this.#offline = offline;
2842
4242
  this.#apiBaseUrl = apiBaseUrl.replace(/\/+$/u, "");
2843
4243
  this.#connection = connection;
2844
4244
  this.#store = store;
@@ -2854,9 +4254,12 @@ var ChatControllerRuntime = class {
2854
4254
  Effect4.tap(() => Effect4.sync(() => operation?.observe("observed", "validation"))),
2855
4255
  Effect4.map((attachments) => ({ clientMessageId: this.#connection.createId(), attachments })),
2856
4256
  Effect4.flatMap(({ clientMessageId, attachments }) => {
2857
- this.#upsertPending(clientMessageId, input.text, attachments, "sending", null);
4257
+ const command = this.#connection.runCommand(({ sync }) => foreign(() => sync.sendChatMessage({ text: input.text, attachments, clientMessageId })));
4258
+ const sendNow = () => Effect4.sync(() => this.#upsertPending(clientMessageId, input.text, attachments, "sending", null)).pipe(Effect4.andThen(command));
4259
+ const held = this.#offline?.hold({ kind: "chat_message", text: input.text, clientMessageId, attachments }, sendNow);
4260
+ if (held) this.#upsertPending(clientMessageId, input.text, attachments, "queued", null);
2858
4261
  operation?.observe("observed", "authorization");
2859
- return this.#connection.runCommand(({ sync }) => foreign(() => sync.sendChatMessage({ text: input.text, attachments, clientMessageId }))).pipe(Effect4.tap(() => Effect4.sync(() => operation?.observe("observed", "durable_commit")))).pipe(
4262
+ return (held ?? sendNow()).pipe(Effect4.tap(() => Effect4.sync(() => operation?.observe("observed", "durable_commit")))).pipe(
2860
4263
  Effect4.map(chatMessageFor),
2861
4264
  Effect4.tap(() => Effect4.sync(() => this.#removePending(clientMessageId))),
2862
4265
  Effect4.map((message) => this.#observeMessage(message, false)),
@@ -2870,7 +4273,8 @@ var ChatControllerRuntime = class {
2870
4273
  Effect4.catch((cause) => {
2871
4274
  const error2 = normalizeClientError(cause, "chat.payload_invalid");
2872
4275
  return Effect4.sync(() => {
2873
- this.#upsertPending(clientMessageId, input.text, attachments, "failed", failureFromError(error2));
4276
+ if (error2.code === "offline.discarded") this.#removePending(clientMessageId);
4277
+ else this.#upsertPending(clientMessageId, input.text, attachments, "failed", failureFromError(error2));
2874
4278
  operation?.fail("send_failed");
2875
4279
  }).pipe(Effect4.andThen(Effect4.fail(error2)));
2876
4280
  })
@@ -2930,6 +4334,7 @@ var ChatControllerRuntime = class {
2930
4334
  });
2931
4335
  };
2932
4336
  upload = (file) => {
4337
+ const fetch = this.#fetch;
2933
4338
  const prepareOperation = this.#diagnostics?.startOperation("chat.attachment.prepare");
2934
4339
  return Effect4.suspend(() => {
2935
4340
  if (!this.#transport) {
@@ -2950,7 +4355,7 @@ var ChatControllerRuntime = class {
2950
4355
  prepareOperation?.succeed();
2951
4356
  }).pipe(
2952
4357
  Effect4.andThen(
2953
- foreign(() => this.#fetch(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: bytes })).pipe(
4358
+ foreign(() => fetch(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: bytes })).pipe(
2954
4359
  Effect4.flatMap((response) => {
2955
4360
  const commitOperation = this.#diagnostics?.startOperation("chat.attachment.commit");
2956
4361
  if (!response.ok) {
@@ -2988,7 +4393,11 @@ var ChatControllerRuntime = class {
2988
4393
  );
2989
4394
  });
2990
4395
  };
2991
- url = (attachment) => `${this.#apiBaseUrl}/v1/chat/attachments/${encodeURIComponent(attachment.attachmentId)}/download`;
4396
+ url = (attachment2) => `${this.#apiBaseUrl}/v1/chat/attachments/${encodeURIComponent(attachment2.attachmentId)}/download`;
4397
+ resolveUrl = (attachment2) => Effect4.suspend(() => {
4398
+ if (!this.#transport) return Effect4.fail(new SpaceClientError({ code: "collaboration.unavailable", recoverable: false, message: "Chat file download is unavailable" }));
4399
+ return this.#connection.runPortCommand(() => foreign(() => this.#transport.getDownloadUrl(attachment2.attachmentId)).pipe(Effect4.map((descriptor) => descriptor.downloadUrl))).pipe(Effect4.mapError(normalizeClientError));
4400
+ });
2992
4401
  dispose() {
2993
4402
  this.#unsubscribeConnection?.();
2994
4403
  this.#unsubscribeConnection = null;
@@ -3006,7 +4415,8 @@ var ChatControllerRuntime = class {
3006
4415
  this.#ports = ports;
3007
4416
  if (!ports) return this.#reset();
3008
4417
  this.#status = "ready";
3009
- this.#initialCatchUpComplete = ports.sync.getCollaborationExtensionState().chatHeadSequence === null;
4418
+ if (this.#contiguousSequence === null && ports.sync.getCollaborationExtensionState().chatHeadSequence === null) this.#contiguousSequence = "0";
4419
+ this.#initialCatchUpComplete = this.#contiguousSequence !== null;
3010
4420
  this.#unsubscribeEvents = ports.sync.subscribeCollaboration((event) => {
3011
4421
  if (event.type === "chat_message") this.#observeMessage(chatMessageFor(event.message), this.#initialCatchUpComplete);
3012
4422
  else if (event.type === "chat_read_receipt") this.#mergeReceipt(chatReceiptFor(event.receipt));
@@ -3018,7 +4428,12 @@ var ChatControllerRuntime = class {
3018
4428
  }
3019
4429
  #scheduleCatchUp() {
3020
4430
  const ports = this.#ports;
3021
- if (!ports || ports.sync.getSnapshot().connection.phase !== "live" || !catchUpRequest(this.#messages.at(-1)?.sequence ?? null, ports)) return;
4431
+ const floor = ports?.sync.getCollaborationExtensionState().retainedFloorSequence;
4432
+ if (this.#contiguousSequence === "0" && floor) {
4433
+ this.#contiguousSequence = String(BigInt(floor) - 1n);
4434
+ this.#advanceContiguousSequence();
4435
+ }
4436
+ if (!ports || ports.sync.getSnapshot().connection.phase !== "live" || !catchUpRequest(this.#contiguousSequence, this.#messages.at(-1)?.sequence ?? null, ports)) return;
3022
4437
  if (this.#catchUpRunning) {
3023
4438
  this.#catchUpRequested = true;
3024
4439
  return;
@@ -3038,7 +4453,6 @@ var ChatControllerRuntime = class {
3038
4453
  Effect4.ensuring(
3039
4454
  Effect4.sync(() => {
3040
4455
  this.#catchUpRunning = false;
3041
- this.#initialCatchUpComplete = true;
3042
4456
  if (this.#catchUpRequested) {
3043
4457
  this.#catchUpRequested = false;
3044
4458
  this.#scheduleCatchUp();
@@ -3051,17 +4465,19 @@ var ChatControllerRuntime = class {
3051
4465
  #catchUp(ports) {
3052
4466
  return Effect4.suspend(() => {
3053
4467
  if (this.#ports !== ports || ports.sync.getSnapshot().connection.phase !== "live") return Effect4.void;
3054
- const request = catchUpRequest(this.#messages.at(-1)?.sequence ?? null, ports);
4468
+ const request = catchUpRequest(this.#contiguousSequence, this.#messages.at(-1)?.sequence ?? null, ports);
3055
4469
  if (!request) return Effect4.void;
3056
4470
  return foreign(() => ports.sync.readChatPage(request.input)).pipe(
3057
4471
  Effect4.flatMap((result) => {
3058
4472
  if (this.#ports !== ports) return Effect4.void;
3059
4473
  if (result.status === "cursor_reset") return Effect4.sync(() => this.#applyCursorReset(result.retainedFloorSequence)).pipe(Effect4.andThen(this.#reloadAfterCursorReset(ports)));
3060
4474
  if (request.kind === "initial") {
4475
+ this.#completeInitialPage(ports);
3061
4476
  this.#hasOlder = result.hasOlder;
3062
4477
  this.#publish();
3063
4478
  }
3064
- return result.hasOlder ? this.#catchUp(ports) : Effect4.void;
4479
+ const progressed = request.kind === "initial" || this.#contiguousSequence !== request.input.afterSequence;
4480
+ return progressed ? this.#catchUp(ports) : Effect4.void;
3065
4481
  })
3066
4482
  );
3067
4483
  });
@@ -3071,11 +4487,12 @@ var ChatControllerRuntime = class {
3071
4487
  Effect4.tap(
3072
4488
  (result) => Effect4.sync(() => {
3073
4489
  if (this.#ports !== ports || result.status === "cursor_reset") return;
4490
+ this.#completeInitialPage(ports);
3074
4491
  this.#hasOlder = result.hasOlder;
3075
4492
  this.#publish();
3076
4493
  })
3077
4494
  ),
3078
- Effect4.asVoid
4495
+ Effect4.andThen(this.#catchUp(ports))
3079
4496
  );
3080
4497
  }
3081
4498
  #applyPage(result) {
@@ -3091,8 +4508,32 @@ var ChatControllerRuntime = class {
3091
4508
  this.#hasOlder = false;
3092
4509
  this.#historyTruncated = true;
3093
4510
  this.#retainedFloorSequence = retainedFloorSequence;
4511
+ this.#messages = Object.freeze(this.#messages.filter((message) => compareChatSequence(message.sequence, retainedFloorSequence) >= 0));
4512
+ this.#contiguousSequence = null;
4513
+ this.#initialCatchUpComplete = false;
3094
4514
  this.#publish();
3095
4515
  }
4516
+ #completeInitialPage(ports) {
4517
+ const floor = this.#messages[0]?.sequence ?? ports.sync.getCollaborationExtensionState().retainedFloorSequence;
4518
+ this.#contiguousSequence = floor === null ? "0" : String(BigInt(floor) - 1n);
4519
+ this.#advanceContiguousSequence();
4520
+ this.#initialCatchUpComplete = true;
4521
+ }
4522
+ #advanceContiguousSequence(message) {
4523
+ if (this.#contiguousSequence === null) return;
4524
+ let sequence = BigInt(this.#contiguousSequence);
4525
+ if (message) {
4526
+ if (BigInt(message.sequence) !== sequence + 1n) return;
4527
+ sequence += 1n;
4528
+ }
4529
+ for (const message2 of this.#messages) {
4530
+ const candidate = BigInt(message2.sequence);
4531
+ if (candidate <= sequence) continue;
4532
+ if (candidate !== sequence + 1n) break;
4533
+ sequence = candidate;
4534
+ }
4535
+ this.#contiguousSequence = String(sequence);
4536
+ }
3096
4537
  #observeMessage(message, countUnread) {
3097
4538
  const existing = this.#messages.find((candidate) => candidate.messageId === message.messageId || candidate.sequence === message.sequence);
3098
4539
  const next = this.#messagesWith(message, existing);
@@ -3100,11 +4541,13 @@ var ChatControllerRuntime = class {
3100
4541
  const unread = this.#unreadCountFor(message, existing, countUnread);
3101
4542
  if (this.#messageStateMatches(next, pendingSends, unread)) return existing ?? message;
3102
4543
  this.#messages = next;
4544
+ this.#advanceContiguousSequence(message);
3103
4545
  this.#pendingSends = Object.freeze(pendingSends);
3104
4546
  this.#unreadCount = unread;
3105
4547
  this.#status = "ready";
3106
4548
  this.#lastError = null;
3107
4549
  this.#publish();
4550
+ if (!this.#catchUpRunning && countUnread) this.#scheduleCatchUp();
3108
4551
  return message;
3109
4552
  }
3110
4553
  #messagesWith(message, existing) {
@@ -3149,6 +4592,7 @@ var ChatControllerRuntime = class {
3149
4592
  }
3150
4593
  #reset() {
3151
4594
  this.#catchUpRequested = false;
4595
+ this.#contiguousSequence = null;
3152
4596
  this.#hasOlder = false;
3153
4597
  this.#historyTruncated = false;
3154
4598
  this.#initialCatchUpComplete = true;
@@ -3190,13 +4634,13 @@ function validateUpload(file, bytes, clientAttachmentId) {
3190
4634
  return upload;
3191
4635
  }
3192
4636
  function bytesFor(file) {
3193
- return "bytes" in file ? Effect4.succeed(file.bytes) : foreign(() => file.arrayBuffer());
4637
+ return "fileName" in file ? Effect4.succeed(file.bytes) : foreign(() => file.arrayBuffer());
3194
4638
  }
3195
- function catchUpRequest(latestSequence, ports) {
4639
+ function catchUpRequest(contiguousSequence, latestSequence, ports) {
3196
4640
  const extension = ports.sync.getCollaborationExtensionState();
3197
- const head = extension.chatHeadSequence;
3198
- if (!extension.negotiated || head === null || latestSequence !== null && compareChatSequence(latestSequence, head) >= 0) return null;
3199
- return latestSequence === null ? { kind: "initial", input: { limit: MAX_CHAT_PAGE_SIZE } } : { kind: "newer", input: { afterSequence: latestSequence, limit: MAX_CHAT_PAGE_SIZE } };
4641
+ const head = latestSequence !== null && (extension.chatHeadSequence === null || compareChatSequence(latestSequence, extension.chatHeadSequence) > 0) ? latestSequence : extension.chatHeadSequence;
4642
+ if (!extension.negotiated || head === null || contiguousSequence !== null && compareChatSequence(contiguousSequence, head) >= 0) return null;
4643
+ return contiguousSequence === null ? { kind: "initial", input: { limit: MAX_CHAT_PAGE_SIZE } } : { kind: "newer", input: { afterSequence: contiguousSequence, limit: MAX_CHAT_PAGE_SIZE } };
3200
4644
  }
3201
4645
  function chatError(message) {
3202
4646
  return new SpaceClientError({ code: "chat.payload_invalid", recoverable: false, message });
@@ -3228,7 +4672,10 @@ var makeMediaController = (connection, store, selection, diagnostics) => Effect5
3228
4672
  fork,
3229
4673
  diagnostics
3230
4674
  );
3231
- yield* connection.setInitialMedia((intent) => controller.captureInitial(intent));
4675
+ yield* connection.setInitialMedia(
4676
+ (intent) => controller.captureInitial(intent),
4677
+ () => controller.initialIntent()
4678
+ );
3232
4679
  yield* Effect5.addFinalizer(() => Effect5.sync(() => controller.dispose()));
3233
4680
  controller.refreshDevicesInBackground();
3234
4681
  return controller;
@@ -3242,6 +4689,10 @@ var MediaControllerRuntime = class {
3242
4689
  #fork;
3243
4690
  #diagnostics;
3244
4691
  #tracks = /* @__PURE__ */ new Map();
4692
+ #observedPublicationIds = /* @__PURE__ */ new Map();
4693
+ #intentRevisions = /* @__PURE__ */ new Map();
4694
+ #pendingEnables = /* @__PURE__ */ new Map();
4695
+ #pendingSources = /* @__PURE__ */ new Set();
3245
4696
  #requestGenerations = /* @__PURE__ */ new Map();
3246
4697
  #intent = { microphone: true, camera: true };
3247
4698
  #requests = Object.freeze([]);
@@ -3319,14 +4770,22 @@ var MediaControllerRuntime = class {
3319
4770
  Effect5.tapError(() => Effect5.sync(() => operation?.fail("decline_failed")))
3320
4771
  );
3321
4772
  };
3322
- captureInitial(_intent) {
3323
- if (!this.#intent.microphone && !this.#intent.camera) return Effect5.succeed(streamFromTracks([]));
3324
- return foreign2(() => this.#selection.getUserMedia({ audio: this.#intent.microphone, video: this.#intent.camera })).pipe(
4773
+ initialIntent() {
4774
+ return { ...this.#intent };
4775
+ }
4776
+ captureInitial(intent) {
4777
+ if (!intent.microphone && !intent.camera) {
4778
+ this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
4779
+ this.#publish();
4780
+ return Effect5.succeed(streamFromTracks([...this.#tracks.values()]));
4781
+ }
4782
+ return foreign2(() => this.#selection.getUserMedia({ audio: intent.microphone, video: intent.camera })).pipe(
3325
4783
  Effect5.flatMap(
3326
- (stream) => Effect5.try({ try: () => selectInitialTracks(stream, this.#intent), catch: (cause) => cause }).pipe(
4784
+ (stream) => Effect5.try({ try: () => selectInitialTracks(stream, intent), catch: (cause) => cause }).pipe(
3327
4785
  Effect5.tap(
3328
4786
  (tracks) => Effect5.sync(() => {
3329
4787
  for (const [source, track] of tracks) this.#tracks.set(source, track);
4788
+ this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
3330
4789
  this.#publish();
3331
4790
  })
3332
4791
  ),
@@ -3368,161 +4827,268 @@ var MediaControllerRuntime = class {
3368
4827
  #set(source, enabled) {
3369
4828
  const operation = this.#diagnostics?.startOperation(`${source}.${enabled ? "publish" : "unpublish"}`);
3370
4829
  operation?.observe("observed", "intent");
3371
- return this.#serialize(
3372
- source,
3373
- this.#connection.runCommand((ports) => {
3374
- const previousIntent = this.#intent[source];
3375
- this.#intent[source] = enabled;
3376
- let captured = null;
3377
- let prepared = false;
3378
- const action = source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled";
3379
- return Effect5.suspend(() => {
3380
- const acquire = enabled && !this.#tracks.has(source) ? this.#captureSource(source).pipe(
3381
- Effect5.tap(
3382
- (track) => Effect5.sync(() => {
3383
- captured = track;
3384
- try {
3385
- this.#assertActivePorts(ports, action);
3386
- } catch (cause) {
3387
- track.stop();
3388
- throw cause;
3389
- }
3390
- this.#tracks.set(source, track);
3391
- ports.media.prepareLocalTrack(source, track);
3392
- prepared = true;
3393
- operation?.observe("observed", "local_track_state");
3394
- this.#publish();
3395
- })
3396
- )
3397
- ) : Effect5.void;
3398
- return acquire.pipe(
3399
- Effect5.asVoid,
3400
- Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "local_track_state"))),
3401
- Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(enabled)) : foreign2(() => ports.sync.setCameraEnabled(enabled))),
3402
- Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
3403
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, action))),
3404
- Effect5.tap(
3405
- () => Effect5.sync(() => {
3406
- operation?.observe("observed", "sfu_publication");
3407
- operation?.succeed();
3408
- })
3409
- ),
3410
- Effect5.asVoid,
3411
- Effect5.catch(
3412
- (cause) => this.#rollbackCapture({
3413
- action,
3414
- cause,
3415
- fallback: Effect5.void,
3416
- media: ports.media,
3417
- permissionMessage: `${source} permission was denied`,
3418
- prepared,
3419
- restoreIntent: () => {
3420
- this.#intent[source] = previousIntent;
3421
- },
3422
- source,
3423
- track: captured
3424
- })
3425
- )
3426
- );
3427
- });
3428
- })
4830
+ let previousIntent = this.#intent[source];
4831
+ let intentRevision = 0;
4832
+ let earlyPause = null;
4833
+ const localPause = Effect5.suspend(() => {
4834
+ previousIntent = this.#intent[source];
4835
+ intentRevision = (this.#intentRevisions.get(source) ?? 0) + 1;
4836
+ this.#intentRevisions.set(source, intentRevision);
4837
+ this.#intent[source] = enabled;
4838
+ if (enabled) this.#pendingEnables.set(source, intentRevision);
4839
+ this.#ports?.media.setLocalSourceIntent?.(source, enabled);
4840
+ if (!enabled && this.#ports) earlyPause = this.#beginLocalPause(this.#ports, source);
4841
+ return Effect5.void;
4842
+ });
4843
+ return localPause.pipe(
4844
+ Effect5.andThen(
4845
+ this.#serialize(
4846
+ source,
4847
+ this.#connection.runCommand((ports) => {
4848
+ let captured = null;
4849
+ let prepared = false;
4850
+ const action = source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled";
4851
+ return Effect5.suspend(() => {
4852
+ if (!enabled) {
4853
+ let paused = false;
4854
+ return (earlyPause ? foreign2(() => earlyPause) : this.#localTarget(ports, source, false)).pipe(
4855
+ Effect5.tap(
4856
+ () => Effect5.sync(() => {
4857
+ paused = true;
4858
+ })
4859
+ ),
4860
+ Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "local_track_state"))),
4861
+ Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(false)) : foreign2(() => ports.sync.setCameraEnabled(false))),
4862
+ Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
4863
+ Effect5.tap(() => Effect5.sync(() => operation?.succeed())),
4864
+ Effect5.asVoid,
4865
+ Effect5.catch((cause) => {
4866
+ if (paused && !isSyncRejection(cause) || this.#intentRevisions.get(source) !== intentRevision) return Effect5.fail(cause);
4867
+ this.#intent[source] = previousIntent;
4868
+ ports.media.setLocalSourceIntent?.(source, previousIntent);
4869
+ return this.#localTarget(ports, source, previousIntent).pipe(
4870
+ Effect5.catch(() => Effect5.void),
4871
+ Effect5.andThen(Effect5.fail(cause))
4872
+ );
4873
+ })
4874
+ );
4875
+ }
4876
+ const acquire = enabled && !this.#tracks.has(source) ? this.#captureSource(source).pipe(
4877
+ Effect5.tap(
4878
+ (track) => Effect5.sync(() => {
4879
+ captured = track;
4880
+ try {
4881
+ this.#assertActivePorts(ports, action);
4882
+ } catch (cause) {
4883
+ track.stop();
4884
+ throw cause;
4885
+ }
4886
+ this.#tracks.set(source, track);
4887
+ ports.media.prepareLocalTrack(source, track);
4888
+ prepared = true;
4889
+ operation?.observe("observed", "local_track_state");
4890
+ this.#publish();
4891
+ })
4892
+ )
4893
+ ) : Effect5.void;
4894
+ return acquire.pipe(
4895
+ Effect5.asVoid,
4896
+ Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "local_track_state"))),
4897
+ Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(enabled)) : foreign2(() => ports.sync.setCameraEnabled(enabled))),
4898
+ Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
4899
+ Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, action))),
4900
+ Effect5.tap(
4901
+ () => Effect5.sync(() => {
4902
+ operation?.observe("observed", "sfu_publication");
4903
+ operation?.succeed();
4904
+ })
4905
+ ),
4906
+ Effect5.asVoid,
4907
+ Effect5.catch(
4908
+ (cause) => this.#rollbackCapture({
4909
+ action,
4910
+ cause,
4911
+ fallback: Effect5.void,
4912
+ media: ports.media,
4913
+ permissionMessage: `${source} permission was denied`,
4914
+ prepared,
4915
+ restoreIntent: () => {
4916
+ if (this.#intentRevisions.get(source) === intentRevision) this.#intent[source] = previousIntent;
4917
+ },
4918
+ source,
4919
+ track: captured
4920
+ })
4921
+ )
4922
+ );
4923
+ });
4924
+ })
4925
+ )
4926
+ )
3429
4927
  ).pipe(
3430
4928
  Effect5.mapError(normalizeClientError),
3431
- Effect5.tapError(() => Effect5.sync(() => operation?.fail("media_failed")))
4929
+ Effect5.tapError(() => Effect5.sync(() => operation?.fail("media_failed"))),
4930
+ Effect5.ensuring(
4931
+ Effect5.sync(() => {
4932
+ if (this.#pendingEnables.get(source) === intentRevision) this.#pendingEnables.delete(source);
4933
+ })
4934
+ )
3432
4935
  );
3433
4936
  }
3434
4937
  #startScreen() {
3435
4938
  const operation = this.#diagnostics?.startOperation("screen.start");
3436
- return this.#serialize(
3437
- "screen",
3438
- this.#connection.runCommand((ports) => {
3439
- let stream = null;
3440
- let track = null;
3441
- let prepared = false;
3442
- return Effect5.suspend(() => {
3443
- if (this.#tracks.has("screen")) {
3444
- operation?.notObservable("permission", "already_active");
3445
- operation?.notObservable("track_acquisition", "already_active");
3446
- operation?.notObservable("sync_commit", "already_active");
3447
- operation?.notObservable("sfu_publication", "already_active");
3448
- operation?.succeed();
3449
- return Effect5.void;
3450
- }
3451
- return foreign2(() => this.#selection.getDisplayMedia({ video: true, audio: false })).pipe(
3452
- Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "permission"))),
3453
- Effect5.tap(
3454
- (captured) => Effect5.sync(() => {
3455
- stream = captured;
3456
- this.#assertActivePorts(ports, "startScreenShare");
3457
- track = requireDisplayVideoTrack(captured);
3458
- this.#tracks.set("screen", track);
3459
- ports.media.prepareLocalTrack("screen", track);
3460
- prepared = true;
3461
- operation?.observe("observed", "track_acquisition");
3462
- track.addEventListener("ended", () => this.#handleScreenEnded(track));
3463
- this.#screenEndedPending = false;
3464
- this.#publish();
3465
- })
3466
- ),
3467
- Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(true))),
3468
- Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
3469
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "startScreenShare"))),
3470
- Effect5.tap(
3471
- () => Effect5.sync(() => {
3472
- operation?.observe("observed", "sfu_publication");
4939
+ let intentRevision = 0;
4940
+ return Effect5.sync(() => {
4941
+ intentRevision = (this.#intentRevisions.get("screen") ?? 0) + 1;
4942
+ this.#intentRevisions.set("screen", intentRevision);
4943
+ this.#pendingEnables.set("screen", intentRevision);
4944
+ this.#ports?.media.setLocalSourceIntent?.("screen", true);
4945
+ }).pipe(
4946
+ Effect5.andThen(
4947
+ this.#serialize(
4948
+ "screen",
4949
+ this.#connection.runCommand((ports) => {
4950
+ let stream = null;
4951
+ let track = null;
4952
+ let prepared = false;
4953
+ return Effect5.suspend(() => {
4954
+ if (this.#tracks.has("screen")) {
4955
+ operation?.notObservable("permission", "already_active");
4956
+ operation?.notObservable("track_acquisition", "already_active");
4957
+ operation?.notObservable("sync_commit", "already_active");
4958
+ operation?.notObservable("sfu_publication", "already_active");
3473
4959
  operation?.succeed();
3474
- })
3475
- ),
3476
- Effect5.asVoid,
3477
- Effect5.catch((cause) => this.#rollbackCapture({ action: "startScreenShare", cause, fallback: Effect5.sync(() => stopStream(stream)), media: ports.media, permissionMessage: "Screen sharing permission was denied", prepared, source: "screen", track }))
3478
- );
3479
- });
3480
- })
4960
+ return Effect5.void;
4961
+ }
4962
+ return foreign2(() => this.#selection.getDisplayMedia({ video: true, audio: false })).pipe(
4963
+ Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "permission"))),
4964
+ Effect5.tap(
4965
+ (captured) => Effect5.sync(() => {
4966
+ stream = captured;
4967
+ this.#assertActivePorts(ports, "startScreenShare");
4968
+ track = requireDisplayVideoTrack(captured);
4969
+ this.#tracks.set("screen", track);
4970
+ ports.media.prepareLocalTrack("screen", track);
4971
+ prepared = true;
4972
+ operation?.observe("observed", "track_acquisition");
4973
+ track.addEventListener("ended", () => this.#handleScreenEnded(track));
4974
+ this.#screenEndedPending = false;
4975
+ this.#publish();
4976
+ })
4977
+ ),
4978
+ Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(true))),
4979
+ Effect5.tap(() => Effect5.sync(() => operation?.observe("observed", "sync_commit"))),
4980
+ Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "startScreenShare"))),
4981
+ Effect5.tap(
4982
+ () => Effect5.sync(() => {
4983
+ operation?.observe("observed", "sfu_publication");
4984
+ operation?.succeed();
4985
+ })
4986
+ ),
4987
+ Effect5.asVoid,
4988
+ Effect5.catch((cause) => this.#rollbackCapture({ action: "startScreenShare", cause, fallback: Effect5.sync(() => stopStream(stream)), media: ports.media, permissionMessage: "Screen sharing permission was denied", prepared, source: "screen", track }))
4989
+ );
4990
+ });
4991
+ })
4992
+ )
4993
+ )
3481
4994
  ).pipe(
3482
4995
  Effect5.mapError(normalizeClientError),
3483
- Effect5.tapError(() => Effect5.sync(() => operation?.fail("screen_start_failed")))
4996
+ Effect5.tapError(() => Effect5.sync(() => operation?.fail("screen_start_failed"))),
4997
+ Effect5.ensuring(
4998
+ Effect5.sync(() => {
4999
+ if (this.#pendingEnables.get("screen") === intentRevision) this.#pendingEnables.delete("screen");
5000
+ })
5001
+ )
3484
5002
  );
3485
5003
  }
3486
5004
  #stopScreen() {
3487
5005
  const operation = this.#diagnostics?.startOperation("screen.stop");
3488
- return this.#serialize(
3489
- "screen",
3490
- this.#connection.runCommand(
3491
- (ports) => Effect5.suspend(() => {
3492
- const track = this.#tracks.get("screen");
3493
- if (!track) {
3494
- operation?.notObservable("stop_confirmation", "already_stopped");
3495
- operation?.succeed();
3496
- return Effect5.void;
3497
- }
3498
- return foreign2(() => ports.sync.setScreenShareEnabled(false)).pipe(
3499
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "stopScreenShare"))),
3500
- Effect5.andThen(foreign2(() => ports.media.clearPreparedLocalTrack("screen"))),
3501
- Effect5.tap(
3502
- () => Effect5.sync(() => {
3503
- this.#assertActivePorts(ports, "stopScreenShare");
3504
- if (this.#tracks.get("screen") === track) this.#tracks.delete("screen");
3505
- track.stop();
3506
- this.#screenEndedPending = false;
3507
- this.#publish();
3508
- operation?.observe("observed", "stop_confirmation");
3509
- operation?.succeed();
3510
- })
3511
- ),
3512
- Effect5.asVoid
3513
- );
3514
- })
5006
+ let intentRevision = 0;
5007
+ let earlyPause = null;
5008
+ const localPause = Effect5.suspend(() => {
5009
+ intentRevision = (this.#intentRevisions.get("screen") ?? 0) + 1;
5010
+ this.#intentRevisions.set("screen", intentRevision);
5011
+ this.#ports?.media.setLocalSourceIntent?.("screen", false);
5012
+ if (this.#ports) earlyPause = this.#beginLocalPause(this.#ports, "screen");
5013
+ return Effect5.void;
5014
+ });
5015
+ return localPause.pipe(
5016
+ Effect5.andThen(
5017
+ this.#serialize(
5018
+ "screen",
5019
+ this.#connection.runCommand(
5020
+ (ports) => Effect5.suspend(() => {
5021
+ const track = this.#tracks.get("screen");
5022
+ let paused = false;
5023
+ return (earlyPause ? foreign2(() => earlyPause) : this.#localTarget(ports, "screen", false)).pipe(
5024
+ Effect5.tap(
5025
+ () => Effect5.sync(() => {
5026
+ paused = true;
5027
+ })
5028
+ ),
5029
+ Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(false))),
5030
+ Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "stopScreenShare"))),
5031
+ Effect5.andThen(foreign2(() => ports.media.clearPreparedLocalTrack("screen"))),
5032
+ Effect5.tap(
5033
+ () => Effect5.sync(() => {
5034
+ this.#assertActivePorts(ports, "stopScreenShare");
5035
+ if (this.#tracks.get("screen") === track) this.#tracks.delete("screen");
5036
+ track?.stop();
5037
+ this.#screenEndedPending = false;
5038
+ this.#publish();
5039
+ operation?.observe("observed", "stop_confirmation");
5040
+ operation?.succeed();
5041
+ })
5042
+ ),
5043
+ Effect5.asVoid,
5044
+ Effect5.catch(
5045
+ (cause) => paused && !isSyncRejection(cause) || this.#intentRevisions.get("screen") !== intentRevision ? Effect5.fail(cause) : Effect5.sync(() => ports.media.setLocalSourceIntent?.("screen", true)).pipe(
5046
+ Effect5.andThen(this.#localTarget(ports, "screen", true)),
5047
+ Effect5.catch(() => Effect5.void),
5048
+ Effect5.andThen(Effect5.fail(cause))
5049
+ )
5050
+ )
5051
+ );
5052
+ })
5053
+ )
5054
+ )
3515
5055
  )
3516
5056
  ).pipe(
3517
5057
  Effect5.mapError(normalizeClientError),
3518
5058
  Effect5.tapError(() => Effect5.sync(() => operation?.fail("screen_stop_failed")))
3519
5059
  );
3520
5060
  }
5061
+ #beginLocalPause(ports, source) {
5062
+ const pause = Effect5.runPromise(this.#localTarget(ports, source, false));
5063
+ void pause.catch(() => void 0);
5064
+ return pause;
5065
+ }
3521
5066
  #serialize(source, effect) {
3522
- return this.#gates.get(source).withPermit(effect);
5067
+ return this.#gates.get(source).withPermit(
5068
+ Effect5.sync(() => {
5069
+ this.#pendingSources.add(source);
5070
+ this.#publish();
5071
+ }).pipe(
5072
+ Effect5.andThen(effect),
5073
+ Effect5.ensuring(
5074
+ Effect5.sync(() => {
5075
+ this.#pendingSources.delete(source);
5076
+ this.#publish();
5077
+ })
5078
+ )
5079
+ )
5080
+ );
5081
+ }
5082
+ #localTarget(ports, source, enabled) {
5083
+ return foreign2(() => ports.media.setLocalPublicationTarget({ operationId: `privacy-${source}-${enabled ? "restore" : "off"}`, participantId: ports.sync.getSnapshot().participantId ?? "", source, enabled })).pipe(
5084
+ Effect5.flatMap(
5085
+ (result) => result.outcome === "confirmed" || result.outcome === "satisfied" || !enabled && result.errorCode === "source_unavailable" ? Effect5.void : Effect5.fail(new SpaceClientError({ code: "media.capture_failed", recoverable: true, message: `Could not ${enabled ? "restore" : "pause"} ${source}` }))
5086
+ )
5087
+ );
3523
5088
  }
3524
5089
  #captureSource(source) {
3525
5090
  return foreign2(() => this.#selection.getUserMedia({ audio: source === "microphone", video: source === "camera" })).pipe(
5091
+ Effect5.mapError((cause) => captureError(cause, source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled", `${source} permission was denied`)),
3526
5092
  Effect5.tap(() => this.#refreshDevices().pipe(Effect5.ignore)),
3527
5093
  Effect5.flatMap(
3528
5094
  (stream) => Effect5.try({
@@ -3568,8 +5134,8 @@ var MediaControllerRuntime = class {
3568
5134
  input.restoreIntent?.();
3569
5135
  const rollback = input.track ? this.#discardCaptured(input.source, input.track, input.media, input.prepared) : input.fallback;
3570
5136
  const captureWasUnavailable = input.action === "startScreenShare" && !input.prepared && input.track === null;
3571
- const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
3572
- return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure2)));
5137
+ const failure3 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
5138
+ return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure3)));
3573
5139
  }
3574
5140
  #assertActivePorts(ports, action) {
3575
5141
  if (this.#connection.getSnapshot().state === "live" && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
@@ -3582,6 +5148,7 @@ var MediaControllerRuntime = class {
3582
5148
  this.#unsubscribeMedia = null;
3583
5149
  this.#unsubscribeRequests = null;
3584
5150
  this.#unsubscribeSync = null;
5151
+ if (this.#ports?.media !== ports?.media) this.#observedPublicationIds.clear();
3585
5152
  this.#ports = ports;
3586
5153
  if (!ports) {
3587
5154
  for (const track of this.#tracks.values()) track.stop();
@@ -3591,11 +5158,57 @@ var MediaControllerRuntime = class {
3591
5158
  this.#publish();
3592
5159
  return;
3593
5160
  }
3594
- this.#unsubscribeMedia = ports.media.subscribe(() => this.#publish());
5161
+ this.#unsubscribeMedia = ports.media.subscribe(() => {
5162
+ this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
5163
+ this.#publish();
5164
+ });
3595
5165
  this.#unsubscribeRequests = ports.sync.onDirectedRequest((request) => this.#request(request));
3596
- this.#unsubscribeSync = ports.sync.subscribe((snapshot) => this.#removeRequestsFromMissingParticipants(snapshot));
5166
+ this.#unsubscribeSync = ports.sync.subscribe((snapshot) => {
5167
+ this.#removeRequestsFromMissingParticipants(snapshot);
5168
+ this.#applyRemoteMediaState(ports, snapshot);
5169
+ this.#applyForcedMediaState(ports, snapshot);
5170
+ });
5171
+ this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
5172
+ this.#applyForcedMediaState(ports, ports.sync.getSnapshot());
5173
+ this.#publish();
5174
+ }
5175
+ #applyRemoteMediaState(ports, snapshot) {
5176
+ for (const publication of ports.media.getSnapshot().remoteTracks) {
5177
+ publication.track.enabled = snapshot.media?.items.some((item) => item.participantId === publication.participantId && item.source === publication.source && item.enabled) ?? false;
5178
+ }
5179
+ }
5180
+ #applyForcedMediaState(ports, snapshot) {
5181
+ if (!canApplyForcedMediaState(snapshot)) return;
5182
+ for (const source of MEDIA_SOURCES) {
5183
+ const projected = snapshot.media.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5184
+ if (projected?.publicationId) {
5185
+ this.#observedPublicationIds.set(source, projected.publicationId);
5186
+ continue;
5187
+ }
5188
+ const previousPublicationId = this.#observedPublicationIds.get(source);
5189
+ if (!this.#observedPublicationIds.delete(source)) continue;
5190
+ const local = ports.media.getSnapshot().localTracks.find((publication) => publication.source === source);
5191
+ if (local && local.publicationId !== previousPublicationId) continue;
5192
+ this.#pauseForcedSource(ports, source);
5193
+ }
5194
+ }
5195
+ #pauseForcedSource(ports, source) {
5196
+ const pendingEnable = this.#hasPendingEnable(source);
5197
+ if (!pendingEnable) {
5198
+ this.#intentRevisions.set(source, (this.#intentRevisions.get(source) ?? 0) + 1);
5199
+ if (source !== "screen") this.#intent[source] = false;
5200
+ }
5201
+ const track = this.#tracks.get(source);
5202
+ if (track) track.enabled = false;
5203
+ ports.media.setLocalSourceIntent?.(source, false);
5204
+ if (ports.media.closeForcedLocalPublication) void ports.media.closeForcedLocalPublication(source).catch(() => void 0);
5205
+ if (pendingEnable) ports.media.setLocalSourceIntent?.(source, true);
3597
5206
  this.#publish();
3598
5207
  }
5208
+ #hasPendingEnable(source) {
5209
+ const revision = this.#pendingEnables.get(source);
5210
+ return revision !== void 0 && revision === this.#intentRevisions.get(source);
5211
+ }
3599
5212
  #request(request) {
3600
5213
  if (request.expires_at_ms <= this.#now()) return;
3601
5214
  const control = this.#ports?.sync.getSnapshot().optimisticControl ?? this.#ports?.sync.getSnapshot().control;
@@ -3665,7 +5278,11 @@ var MediaControllerRuntime = class {
3665
5278
  const current = this.#store.getSnapshot();
3666
5279
  const snapshot = this.#ports?.media.getSnapshot();
3667
5280
  const state = this.#connection.getSnapshot().state;
3668
- const local = Object.freeze({ microphone: localMedia("microphone", this.#tracks, snapshot, this.#intent.microphone, state), camera: localMedia("camera", this.#tracks, snapshot, this.#intent.camera, state), screen: localMedia("screen", this.#tracks, snapshot, false, state) });
5281
+ const local = Object.freeze({
5282
+ microphone: localMedia("microphone", this.#tracks, snapshot, this.#intent.microphone, state, this.#pendingSources.has("microphone")),
5283
+ camera: localMedia("camera", this.#tracks, snapshot, this.#intent.camera, state, this.#pendingSources.has("camera")),
5284
+ screen: localMedia("screen", this.#tracks, snapshot, false, state, this.#pendingSources.has("screen"))
5285
+ });
3669
5286
  const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
3670
5287
  const media = Object.freeze({ ...current.media, local, remote, screenShare: local.screen, incomingRequests: this.#requests });
3671
5288
  if (!sameMediaSlice(current.media, media)) this.#store.updateMedia(media);
@@ -3702,12 +5319,18 @@ function initialTrackEntries(microphone, camera) {
3702
5319
  if (camera) entries.push(["camera", camera]);
3703
5320
  return entries;
3704
5321
  }
3705
- function localMedia(source, tracks, snapshot, intended, connectionState) {
5322
+ function localMedia(source, tracks, snapshot, intended, connectionState, pending) {
3706
5323
  const track = tracks.get(source) ?? null;
3707
5324
  const publication = snapshot?.localTracks.find((candidate) => candidate.source === source);
5325
+ if (locallyMuted(intended, track, publication)) return Object.freeze({ source, state: "disabled", track });
5326
+ if (pending) return Object.freeze({ source, state: "requesting", track });
3708
5327
  if (publication?.enabled) return Object.freeze({ source, state: "enabled", track });
5328
+ if (publication && connectionState === "live") return Object.freeze({ source, state: "disabled", track });
3709
5329
  return Object.freeze({ source, state: localMediaState(source, track, intended, connectionState), track });
3710
5330
  }
5331
+ function locallyMuted(intended, track, publication) {
5332
+ return !intended && track?.enabled === false && publication?.enabled === false;
5333
+ }
3711
5334
  function localMediaState(source, track, intended, connectionState) {
3712
5335
  if (!mediaIsDesired(source, track, intended)) return inactiveLocalMediaState(connectionState, track);
3713
5336
  if (connectionState === "joining" || track) return "requesting";
@@ -3750,19 +5373,268 @@ function sameDeviceList(left, right) {
3750
5373
  return left.length === right.length && left.every((device, index) => device.deviceId === right[index]?.deviceId && device.label === right[index]?.label);
3751
5374
  }
3752
5375
  function captureError(cause, action, permissionMessage) {
5376
+ if (cause instanceof ConnectionError) return cause;
3753
5377
  if (isPermissionDenied(cause)) return new ConnectionError({ code: "permission_denied", action, recoverable: true, message: permissionMessage }, { cause });
3754
- return new ConnectionError({ code: "unsupported_environment", action, recoverable: false, message: action === "startScreenShare" ? "Screen sharing is unavailable in this browser." : "Media capture is unavailable" }, { cause });
5378
+ const failure3 = describeMediaCaptureError(cause);
5379
+ return new ConnectionError({ ...failure3, action, message: action === "startScreenShare" ? "Screen sharing could not start. Check screen recording permission and try again in a supported browser or app." : failure3.message }, { cause });
3755
5380
  }
3756
5381
  function isPermissionDenied(cause) {
3757
- return cause instanceof DOMException && (cause.name === "NotAllowedError" || cause.name === "SecurityError");
5382
+ return describeMediaCaptureError(cause).code === "permission_denied";
3758
5383
  }
3759
5384
  function isAccessInvalid2(cause) {
3760
5385
  return typeof cause === "object" && cause !== null && "code" in cause && (cause.code === "access.invalid" || cause.code === "invalid_access");
3761
5386
  }
5387
+ function isSyncRejection(cause) {
5388
+ return isAccessInvalid2(cause) || cause instanceof V1SyncError && cause.code === "terminal_failure";
5389
+ }
5390
+ function canApplyForcedMediaState(snapshot) {
5391
+ return !!snapshot.media && !!snapshot.participantId && snapshot.control?.status !== "ended" && snapshot.connection.phase !== "terminal";
5392
+ }
3762
5393
 
3763
- // src/space-client/participants-controller.ts
5394
+ // src/space-client/offline-actions.ts
3764
5395
  import { Context as Context7, Effect as Effect6, Layer as Layer7 } from "effect";
3765
5396
 
5397
+ // src/space-client/offline-actions-storage.ts
5398
+ import { Schema as Schema3 } from "effect";
5399
+ var OFFLINE_ACTION_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
5400
+ function browserOfflineActionsStorage() {
5401
+ if (typeof window === "undefined") return void 0;
5402
+ try {
5403
+ return globalThis.localStorage;
5404
+ } catch {
5405
+ return void 0;
5406
+ }
5407
+ }
5408
+ var attachment = Schema3.Struct({ attachmentId: Schema3.String, fileName: Schema3.String, mimeType: ChatAttachmentMimeTypeSchema, byteLength: Schema3.Number });
5409
+ var StoredOfflineAction = Schema3.Struct({
5410
+ id: Schema3.String,
5411
+ kind: Schema3.Literals(["chat_message", "hand_raise", "hand_lower"]),
5412
+ text: Schema3.NullOr(Schema3.String),
5413
+ queuedAt: Schema3.Number,
5414
+ clientMessageId: Schema3.optional(Schema3.String),
5415
+ attachments: Schema3.optional(Schema3.Array(attachment))
5416
+ });
5417
+ var StoredOfflineActions = Schema3.Struct({
5418
+ version: Schema3.Literal(1),
5419
+ subject: Schema3.Struct({ tenantId: Schema3.String, spaceId: Schema3.String, episodeId: Schema3.String, participantId: Schema3.String, participantGeneration: Schema3.Number }),
5420
+ actions: Schema3.Array(StoredOfflineAction)
5421
+ });
5422
+ var decodeOfflineActions = Schema3.decodeUnknownSync(StoredOfflineActions);
5423
+
5424
+ // src/space-client/offline-actions.ts
5425
+ var OfflineActionsService = class extends Context7.Service()("@chalk/client/OfflineActions") {
5426
+ };
5427
+ var sameSubject = (current, saved) => !!current && !!saved && current.tenantId === saved.tenantId && current.spaceId === saved.spaceId && current.episodeId === saved.episodeId && current.participantId === saved.participantId && current.participantGeneration === saved.participantGeneration;
5428
+ var terminalConnection = (snapshot) => snapshot.state === "left" || snapshot.state === "leaving" || snapshot.state === "failed" && !snapshot.failure?.recoverable;
5429
+ var validStoredAction = (action, now) => Number.isFinite(action.queuedAt) && action.queuedAt <= now && (action.kind !== "chat_message" || !!action.clientMessageId && action.text !== null && validateChatMessage({ text: action.text, attachments: action.attachments }) === null);
5430
+ var discardedError = () => new SpaceClientError({ code: "offline.discarded", recoverable: false, message: "This action was not sent." });
5431
+ var makeOfflineActions = (input) => Effect6.gen(function* () {
5432
+ const runtime = new OfflineActionsRuntime(input.connection, input.store, input.storage ?? browserOfflineActionsStorage(), input.storageKey);
5433
+ yield* Effect6.addFinalizer(() => Effect6.sync(() => runtime.dispose()));
5434
+ return runtime;
5435
+ });
5436
+ var OfflineActionsRuntime = class {
5437
+ #connection;
5438
+ #store;
5439
+ #unsubscribe;
5440
+ #waiters = [];
5441
+ #decisionNeeded = false;
5442
+ #policy = "send";
5443
+ #policyExplicit = false;
5444
+ #restored = false;
5445
+ #subject = null;
5446
+ #notice;
5447
+ #storage;
5448
+ #storageKey;
5449
+ constructor(connection, store, storage, storageKey = "chalk:offline-actions") {
5450
+ this.#connection = connection;
5451
+ this.#store = store;
5452
+ this.#storage = storage;
5453
+ this.#storageKey = storageKey;
5454
+ this.#unsubscribe = connection.subscribe(() => this.#connectionChanged());
5455
+ this.#connectionChanged();
5456
+ }
5457
+ send = () => Effect6.sync(() => this.#settle("send"));
5458
+ discard = () => Effect6.sync(() => this.#settle("discard"));
5459
+ setPolicy = (policy) => Effect6.sync(() => {
5460
+ this.#policyExplicit = true;
5461
+ this.#policy = policy;
5462
+ if (this.#decisionNeeded && policy !== "ask") this.#settle(policy);
5463
+ else this.#publish();
5464
+ });
5465
+ hold = (input, run) => {
5466
+ if (this.#connection.getSnapshot().state !== "reconnecting" && !this.#decisionNeeded) return null;
5467
+ const queuedSubject = this.#connection.getSnapshot().subject;
5468
+ const action = Object.freeze({ ...input, id: this.#connection.createId(), text: input.text ?? null, queuedAt: this.#connection.nowUnsafe() });
5469
+ return Effect6.callback((resume) => {
5470
+ if (this.#waiters.length === 0) this.#subject = queuedSubject;
5471
+ this.#notice = void 0;
5472
+ this.#waiters = [...this.#waiters, { action, resume }];
5473
+ this.#persist();
5474
+ this.#publish();
5475
+ return Effect6.sync(() => this.#drop(action.id));
5476
+ }).pipe(Effect6.andThen(Effect6.suspend(() => sameSubject(this.#connection.getSnapshot().subject, queuedSubject) ? run() : Effect6.fail(discardedError()))));
5477
+ };
5478
+ dispose() {
5479
+ this.#unsubscribe();
5480
+ const waiters = this.#waiters;
5481
+ this.#waiters = [];
5482
+ for (const waiter of waiters) waiter.resume(Effect6.fail(discardedError()));
5483
+ }
5484
+ #connectionChanged() {
5485
+ const snapshot = this.#connection.getSnapshot();
5486
+ const state = snapshot.state;
5487
+ const terminal = terminalConnection(snapshot);
5488
+ if (state === "live") this.#restore();
5489
+ if (this.#waiters.length === 0) {
5490
+ if (terminal) this.#discardSaved();
5491
+ return;
5492
+ }
5493
+ if (state === "live") {
5494
+ if (!this.#validScope()) {
5495
+ this.#notice = "Your offline actions were not sent because the Episode or participant changed.";
5496
+ this.#settle("discard");
5497
+ return;
5498
+ }
5499
+ if (this.#policy === "ask") {
5500
+ this.#decisionNeeded = true;
5501
+ this.#publish();
5502
+ } else this.#settle(this.#policy);
5503
+ } else if (terminal) {
5504
+ this.#notice = "Your offline actions were not sent because the Episode ended or the connection closed.";
5505
+ this.#settle("discard");
5506
+ this.#clearStorage();
5507
+ }
5508
+ }
5509
+ #settle(decision) {
5510
+ if (decision === "send" && this.#connection.getSnapshot().state !== "live") return;
5511
+ if (decision === "send" && !this.#validScope()) {
5512
+ this.#notice = "Your offline actions were not sent because the Episode or participant changed.";
5513
+ decision = "discard";
5514
+ }
5515
+ if (!this.#clearStorage() && this.#storage) {
5516
+ this.#notice = "Your offline actions could not be sent safely. Try again when storage is available.";
5517
+ this.#publish();
5518
+ return;
5519
+ }
5520
+ const waiters = this.#waiters;
5521
+ this.#waiters = [];
5522
+ this.#decisionNeeded = false;
5523
+ this.#publish();
5524
+ for (const waiter of waiters) this.#resumeWaiter(waiter, decision);
5525
+ this.#publish();
5526
+ }
5527
+ #drop(id) {
5528
+ const next = this.#waiters.filter((waiter) => waiter.action.id !== id);
5529
+ if (next.length === this.#waiters.length) return;
5530
+ this.#waiters = next;
5531
+ this.#persist();
5532
+ if (next.length === 0) this.#decisionNeeded = false;
5533
+ this.#publish();
5534
+ }
5535
+ #validScope() {
5536
+ const current = this.#connection.getSnapshot().subject;
5537
+ const saved = this.#subject;
5538
+ return sameSubject(current, saved);
5539
+ }
5540
+ #clearStorage() {
5541
+ try {
5542
+ this.#storage?.removeItem(this.#storageKey);
5543
+ return true;
5544
+ } catch {
5545
+ return false;
5546
+ }
5547
+ }
5548
+ #persist() {
5549
+ if (!this.#storage || !this.#subject) return;
5550
+ try {
5551
+ if (this.#waiters.length === 0) this.#storage.removeItem(this.#storageKey);
5552
+ else this.#storage.setItem(this.#storageKey, JSON.stringify({ version: 1, subject: this.#subject, actions: this.#waiters.map(({ action }) => action) }));
5553
+ } catch {
5554
+ this.#notice = "Your offline actions are held, but could not be saved. Keep this page open.";
5555
+ }
5556
+ }
5557
+ #restore() {
5558
+ if (this.#restored || !this.#connection.getSnapshot().subject) return;
5559
+ this.#restored = true;
5560
+ if (this.#waiters.length > 0) return;
5561
+ const saved = this.#readStored();
5562
+ if (!saved) return;
5563
+ this.#subject = saved.subject;
5564
+ if (!this.#validScope()) {
5565
+ this.#notice = "Your offline actions were not sent because the Episode or participant changed.";
5566
+ this.#clearStorage();
5567
+ this.#publish();
5568
+ return;
5569
+ }
5570
+ const actions = saved.actions.filter((action) => this.#connection.nowUnsafe() - action.queuedAt < OFFLINE_ACTION_MAX_AGE_MS);
5571
+ if (actions.length !== saved.actions.length) this.#notice = "Your expired offline actions were not sent. Actions are kept for up to 24 hours.";
5572
+ if (!this.#policyExplicit) this.#policy = "ask";
5573
+ const restoredSubject = saved.subject;
5574
+ this.#waiters = actions.map((action) => ({
5575
+ action,
5576
+ resume: (permission) => {
5577
+ const command = this.#connection.runCommand(
5578
+ ({ sync }) => Effect6.tryPromise({
5579
+ try: () => action.kind === "chat_message" ? sync.sendChatMessage({ text: action.text ?? "", clientMessageId: action.clientMessageId ?? action.id, attachments: action.attachments ?? [] }).then(() => void 0) : sync.setHandRaised(action.kind === "hand_raise"),
5580
+ catch: () => discardedError()
5581
+ })
5582
+ );
5583
+ void Effect6.runPromiseExit(
5584
+ permission.pipe(
5585
+ Effect6.andThen(
5586
+ Effect6.suspend(() => sameSubject(this.#connection.getSnapshot().subject, restoredSubject) ? command : Effect6.fail(discardedError())).pipe(
5587
+ Effect6.tapError(
5588
+ () => Effect6.sync(() => {
5589
+ this.#notice = "An offline action was not sent. Please try it again.";
5590
+ this.#publish();
5591
+ })
5592
+ )
5593
+ )
5594
+ )
5595
+ )
5596
+ );
5597
+ }
5598
+ }));
5599
+ this.#persist();
5600
+ this.#publish();
5601
+ }
5602
+ #resumeWaiter(waiter, decision) {
5603
+ const expired = this.#connection.nowUnsafe() - waiter.action.queuedAt >= OFFLINE_ACTION_MAX_AGE_MS;
5604
+ if (expired) this.#notice = "Your expired offline actions were not sent. Actions are kept for up to 24 hours.";
5605
+ waiter.resume(decision === "send" && !expired ? Effect6.void : Effect6.fail(discardedError()));
5606
+ }
5607
+ #discardSaved() {
5608
+ try {
5609
+ if (!this.#storage?.getItem(this.#storageKey)) return;
5610
+ this.#notice = "Your offline actions were not sent because the Episode ended or the connection closed.";
5611
+ this.#clearStorage();
5612
+ } catch {
5613
+ this.#notice = "Saved offline actions could not be read. They will not be sent.";
5614
+ }
5615
+ this.#publish();
5616
+ }
5617
+ #readStored() {
5618
+ try {
5619
+ const raw = this.#storage?.getItem(this.#storageKey);
5620
+ if (!raw) return null;
5621
+ const saved = decodeOfflineActions(JSON.parse(raw));
5622
+ if (saved.actions.some((action) => !validStoredAction(action, this.#connection.nowUnsafe()))) throw new Error("Invalid offline action");
5623
+ return saved;
5624
+ } catch {
5625
+ this.#clearStorage();
5626
+ return null;
5627
+ }
5628
+ }
5629
+ #publish() {
5630
+ const slice = Object.freeze({ pending: Object.freeze(this.#waiters.map((waiter) => waiter.action)), decisionNeeded: this.#decisionNeeded, policy: this.#policy, ...this.#notice ? { notice: this.#notice } : {} });
5631
+ this.#store.updateOffline(slice);
5632
+ }
5633
+ };
5634
+
5635
+ // src/space-client/participants-controller.ts
5636
+ import { Context as Context8, Effect as Effect7, Layer as Layer8 } from "effect";
5637
+
3766
5638
  // src/sync/v1-types.ts
3767
5639
  var V1_CAPABILITIES = [
3768
5640
  "publishAudio",
@@ -3793,19 +5665,21 @@ var V1_CAPABILITIES = [
3793
5665
  // src/space-client/participants-controller.ts
3794
5666
  var CAPABILITIES = new Set(V1_CAPABILITIES);
3795
5667
  var EMPTY_PARTICIPANTS = Object.freeze({ roster: Object.freeze([]), admissionQueue: Object.freeze([]) });
3796
- var ParticipantsControllerService = class extends Context7.Service()("@chalk/client/ParticipantsController") {
5668
+ var ParticipantsControllerService = class extends Context8.Service()("@chalk/client/ParticipantsController") {
3797
5669
  };
3798
- var makeParticipantsController = (connection, store, diagnostics) => Effect6.acquireRelease(
3799
- Effect6.sync(() => new ParticipantsControllerRuntime(connection, store, diagnostics)),
3800
- (controller) => Effect6.sync(() => controller.dispose())
5670
+ var makeParticipantsController = (connection, store, diagnostics, offline) => Effect7.acquireRelease(
5671
+ Effect7.sync(() => new ParticipantsControllerRuntime(connection, store, diagnostics, offline)),
5672
+ (controller) => Effect7.sync(() => controller.dispose())
3801
5673
  );
3802
5674
  var ParticipantsControllerRuntime = class {
3803
5675
  #connection;
3804
5676
  #store;
3805
5677
  #diagnostics;
5678
+ #offline;
3806
5679
  #unsubscribePorts = null;
3807
5680
  #unsubscribe = null;
3808
- constructor(connection, store, diagnostics) {
5681
+ constructor(connection, store, diagnostics, offline) {
5682
+ this.#offline = offline;
3809
5683
  this.#connection = connection;
3810
5684
  this.#store = store;
3811
5685
  this.#diagnostics = diagnostics;
@@ -3831,7 +5705,7 @@ var ParticipantsControllerRuntime = class {
3831
5705
  requestMedia = (participantId, kind) => this.#command("media_request.request", ["capability_decision", "command_commit", "target_result"], () => {
3832
5706
  assertIdentifier(participantId, "participant ID");
3833
5707
  if (kind !== "microphone" && kind !== "camera") throw invalid("Media requests must target a microphone or camera");
3834
- return ({ sync }) => (kind === "microphone" ? foreign3(() => sync.requestUnmute(participantId)) : foreign3(() => sync.requestStartCamera(participantId))).pipe(Effect6.map(directed));
5708
+ return ({ sync }) => (kind === "microphone" ? foreign3(() => sync.requestUnmute(participantId)) : foreign3(() => sync.requestStartCamera(participantId))).pipe(Effect7.map(directed));
3835
5709
  });
3836
5710
  remove = (participantId) => this.#command("moderation.remove", ["capability_decision", "command_commit", "target_delivery"], () => {
3837
5711
  assertIdentifier(participantId, "participant ID");
@@ -3845,16 +5719,16 @@ var ParticipantsControllerRuntime = class {
3845
5719
  assertIdentifier(requestId, "admission request ID");
3846
5720
  return ({ sync }) => foreign3(() => sync.deny(requestId));
3847
5721
  });
3848
- raiseHand = () => this.#command(
3849
- "participant.raised_hand.set",
3850
- ["membership_transition", "participant_result"],
3851
- () => ({ sync }) => foreign3(() => sync.setHandRaised(true))
3852
- );
3853
- lowerHand = () => this.#command(
3854
- "participant.raised_hand.set",
3855
- ["membership_transition", "participant_result"],
3856
- () => ({ sync }) => foreign3(() => sync.setHandRaised(false))
3857
- );
5722
+ raiseHand = () => this.#setHandRaised(true);
5723
+ lowerHand = () => this.#setHandRaised(false);
5724
+ #setHandRaised(raised) {
5725
+ const send = () => this.#command(
5726
+ "participant.raised_hand.set",
5727
+ ["membership_transition", "participant_result"],
5728
+ () => ({ sync }) => foreign3(() => sync.setHandRaised(raised))
5729
+ );
5730
+ return this.#offline?.hold({ kind: raised ? "hand_raise" : "hand_lower" }, send) ?? send();
5731
+ }
3858
5732
  renameSelf = (displayName) => this.#command("participant.rename", ["membership_transition", "participant_result"], () => {
3859
5733
  assertName(displayName, "display name");
3860
5734
  return ({ sync }) => foreign3(() => sync.setDisplayName(displayName));
@@ -3867,18 +5741,18 @@ var ParticipantsControllerRuntime = class {
3867
5741
  }
3868
5742
  #command(name, checkpoints, operation) {
3869
5743
  const diagnostic = this.#diagnostics?.startOperation(name);
3870
- return Effect6.try({ try: operation, catch: normalizeClientError }).pipe(
3871
- Effect6.tap(() => Effect6.sync(() => diagnostic?.observe("observed", checkpoints[0]))),
3872
- Effect6.flatMap((run) => this.#connection.runCommand(run)),
3873
- Effect6.mapError(normalizeClientError),
3874
- Effect6.tap(
3875
- () => Effect6.sync(() => {
5744
+ return Effect7.try({ try: operation, catch: normalizeClientError }).pipe(
5745
+ Effect7.tap(() => Effect7.sync(() => diagnostic?.observe("observed", checkpoints[0]))),
5746
+ Effect7.flatMap((run) => this.#connection.runCommand(run)),
5747
+ Effect7.mapError(normalizeClientError),
5748
+ Effect7.tap(
5749
+ () => Effect7.sync(() => {
3876
5750
  for (const checkpoint of checkpoints.slice(1)) diagnostic?.observe("observed", checkpoint);
3877
5751
  if (name.startsWith("moderation.")) diagnostic?.notObservable("target_application", "target_projection_is_conditional");
3878
5752
  diagnostic?.succeed();
3879
5753
  })
3880
5754
  ),
3881
- Effect6.tapError(() => Effect6.sync(() => diagnostic?.fail("command_failed")))
5755
+ Effect7.tapError(() => Effect7.sync(() => diagnostic?.fail("command_failed")))
3882
5756
  );
3883
5757
  }
3884
5758
  #bind(ports) {
@@ -3900,7 +5774,7 @@ var ParticipantsControllerRuntime = class {
3900
5774
  }
3901
5775
  };
3902
5776
  function foreign3(operation) {
3903
- return Effect6.tryPromise({ try: operation, catch: (cause) => cause });
5777
+ return Effect7.tryPromise({ try: operation, catch: (cause) => cause });
3904
5778
  }
3905
5779
  function assertIdentifier(value, label) {
3906
5780
  if (typeof value !== "string" || value.trim().length === 0) throw invalid(`A non-empty ${label} is required`);
@@ -4010,20 +5884,20 @@ function sameValues(left, right) {
4010
5884
  }
4011
5885
 
4012
5886
  // src/space-client/reactions-controller.ts
4013
- import { Clock as Clock4, Context as Context8, Effect as Effect7, Layer as Layer8 } from "effect";
5887
+ import { Clock as Clock4, Context as Context9, Effect as Effect8, Layer as Layer9 } from "effect";
4014
5888
  var MAX_VISIBLE_REACTIONS = 24;
4015
5889
  var REACTIONS = /* @__PURE__ */ new Set(["\u{1F44D}", "\u2764\uFE0F", "\u{1F602}", "\u{1F62E}", "\u{1F622}", "\u{1F389}"]);
4016
- var ReactionsControllerService = class extends Context8.Service()("@chalk/client/ReactionsController") {
5890
+ var ReactionsControllerService = class extends Context9.Service()("@chalk/client/ReactionsController") {
4017
5891
  };
4018
- var makeReactionsController = (connection, store, diagnostics) => Effect7.gen(function* () {
5892
+ var makeReactionsController = (connection, store, diagnostics) => Effect8.gen(function* () {
4019
5893
  const clock = yield* Clock4.Clock;
4020
- const scope = yield* Effect7.scope;
4021
- const context = yield* Effect7.context();
5894
+ const scope = yield* Effect8.scope;
5895
+ const context = yield* Effect8.context();
4022
5896
  const fork = (effect) => {
4023
- void Effect7.runForkWith(context)(Effect7.forkIn(effect, scope).pipe(Effect7.asVoid));
5897
+ void Effect8.runForkWith(context)(Effect8.forkIn(effect, scope).pipe(Effect8.asVoid));
4024
5898
  };
4025
5899
  const controller = new ReactionsControllerRuntime(connection, store, clock.currentTimeMillisUnsafe, fork, diagnostics);
4026
- yield* Effect7.addFinalizer(() => Effect7.sync(() => controller.dispose()));
5900
+ yield* Effect8.addFinalizer(() => Effect8.sync(() => controller.dispose()));
4027
5901
  return controller;
4028
5902
  });
4029
5903
  var ReactionsControllerRuntime = class {
@@ -4046,26 +5920,26 @@ var ReactionsControllerRuntime = class {
4046
5920
  }
4047
5921
  send = (emoji) => {
4048
5922
  const operation = this.#diagnostics?.startOperation("reaction.send");
4049
- return Effect7.try({
5923
+ return Effect8.try({
4050
5924
  try: () => {
4051
5925
  if (!REACTIONS.has(emoji)) throw new SpaceClientError({ code: "reaction.invalid", recoverable: false, message: "The reaction is not supported" });
4052
5926
  },
4053
5927
  catch: normalizeClientError
4054
5928
  }).pipe(
4055
- Effect7.tap(() => Effect7.sync(() => operation?.observe("observed", "authorization"))),
4056
- Effect7.andThen(this.#connection.runCommand(({ sync }) => foreign4(() => sync.sendReaction(emoji)))),
4057
- Effect7.tap(() => Effect7.sync(() => operation?.observe("observed", "accepted_commit"))),
4058
- Effect7.map(reactionFor),
4059
- Effect7.tap(
4060
- (reaction) => Effect7.sync(() => {
5929
+ Effect8.tap(() => Effect8.sync(() => operation?.observe("observed", "authorization"))),
5930
+ Effect8.andThen(this.#connection.runCommand(({ sync }) => foreign4(() => sync.sendReaction(emoji)))),
5931
+ Effect8.tap(() => Effect8.sync(() => operation?.observe("observed", "accepted_commit"))),
5932
+ Effect8.map(reactionFor),
5933
+ Effect8.tap(
5934
+ (reaction) => Effect8.sync(() => {
4061
5935
  this.#observe(reaction);
4062
5936
  operation?.observe("observed", "sender_result");
4063
5937
  operation?.notObservable("recipient_projection", "recipient_projection_is_conditional");
4064
5938
  operation?.succeed();
4065
5939
  })
4066
5940
  ),
4067
- Effect7.mapError(normalizeClientError),
4068
- Effect7.tapError(() => Effect7.sync(() => operation?.fail("send_failed")))
5941
+ Effect8.mapError(normalizeClientError),
5942
+ Effect8.tapError(() => Effect8.sync(() => operation?.fail("send_failed")))
4069
5943
  );
4070
5944
  };
4071
5945
  dispose() {
@@ -4095,9 +5969,9 @@ var ReactionsControllerRuntime = class {
4095
5969
  this.#expiryGenerations.set(reaction.eventId, generation);
4096
5970
  const delay = Math.max(0, expiresAt2 - this.#now());
4097
5971
  this.#fork(
4098
- Effect7.sleep(delay).pipe(
4099
- Effect7.andThen(
4100
- Effect7.sync(() => {
5972
+ Effect8.sleep(delay).pipe(
5973
+ Effect8.andThen(
5974
+ Effect8.sync(() => {
4101
5975
  if (this.#expiryGenerations.get(reaction.eventId) === generation) this.#remove(reaction.eventId);
4102
5976
  })
4103
5977
  )
@@ -4124,7 +5998,7 @@ var ReactionsControllerRuntime = class {
4124
5998
  }
4125
5999
  };
4126
6000
  function foreign4(operation) {
4127
- return Effect7.tryPromise({ try: operation, catch: (cause) => cause });
6001
+ return Effect8.tryPromise({ try: operation, catch: (cause) => cause });
4128
6002
  }
4129
6003
  function reactionFor(reaction) {
4130
6004
  return Object.freeze({ ...reaction });
@@ -4139,23 +6013,122 @@ function sameReactions(left, right) {
4139
6013
  });
4140
6014
  }
4141
6015
 
6016
+ // src/space-client/recording-controller.ts
6017
+ import { Context as Context10, Effect as Effect9, Layer as Layer10 } from "effect";
6018
+ var RecordingControllerService = class extends Context10.Service()("@chalk/client/RecordingController") {
6019
+ };
6020
+ var makeRecordingController = (connection, store, diagnostics) => Effect9.acquireRelease(
6021
+ Effect9.sync(() => new RecordingControllerRuntime(connection, store, diagnostics)),
6022
+ (controller) => Effect9.sync(() => controller.dispose())
6023
+ );
6024
+ var RecordingControllerRuntime = class {
6025
+ #connection;
6026
+ #store;
6027
+ #diagnostics;
6028
+ #subscriptions = { ports: null, recording: null };
6029
+ constructor(connection, store, diagnostics) {
6030
+ this.#connection = connection;
6031
+ this.#store = store;
6032
+ this.#diagnostics = diagnostics;
6033
+ this.#subscriptions.ports = connection.subscribePorts((ports) => this.#bind(ports));
6034
+ }
6035
+ start = () => this.#command(
6036
+ "recording.start",
6037
+ "start_failed",
6038
+ () => this.#authorizeStart(),
6039
+ (_, { sync }) => foreign5(() => sync.startRecording()).pipe(Effect9.map(({ recordingId }) => Object.freeze({ recordingId })))
6040
+ );
6041
+ stop = () => this.#command(
6042
+ "recording.stop",
6043
+ "stop_failed",
6044
+ () => this.#authorizeStop(),
6045
+ (recordingId, { sync }) => foreign5(() => sync.stopRecording(recordingId)).pipe(Effect9.asVoid)
6046
+ );
6047
+ dispose() {
6048
+ this.#subscriptions.ports?.();
6049
+ this.#subscriptions.recording?.();
6050
+ this.#subscriptions.ports = null;
6051
+ this.#subscriptions.recording = null;
6052
+ this.#store.updateRecording(EMPTY_RECORDING);
6053
+ }
6054
+ #authorizeStart() {
6055
+ const snapshot = this.#store.getSnapshot();
6056
+ assertConnectedAndAllowed(snapshot);
6057
+ const current = snapshot.recording.current;
6058
+ if (current && current.status !== "stopped" && current.status !== "failed") throw invalidState("A Recording is already active");
6059
+ }
6060
+ #authorizeStop() {
6061
+ const snapshot = this.#store.getSnapshot();
6062
+ assertConnectedAndAllowed(snapshot);
6063
+ const current = snapshot.recording.current;
6064
+ if (!current || current.status !== "recording") throw invalidState("No active Recording can be stopped");
6065
+ return current.recordingId;
6066
+ }
6067
+ #command(name, failureCode, authorize, execute) {
6068
+ const operation = this.#diagnostics?.startOperation(name);
6069
+ const authorized = Effect9.try({ try: authorize, catch: normalizeClientError });
6070
+ return authorized.pipe(
6071
+ Effect9.tap(() => Effect9.sync(() => operation?.observe("observed", "authorization"))),
6072
+ Effect9.flatMap((authorization) => this.#connection.runCommand((ports) => execute(authorization, ports))),
6073
+ Effect9.tap(() => Effect9.sync(() => operation?.observe("observed", "accepted_commit"))),
6074
+ Effect9.tap(() => Effect9.sync(() => operation?.succeed())),
6075
+ Effect9.mapError(normalizeClientError),
6076
+ Effect9.tapError(() => Effect9.sync(() => operation?.fail(failureCode)))
6077
+ );
6078
+ }
6079
+ #bind(ports) {
6080
+ this.#subscriptions.recording?.();
6081
+ this.#subscriptions.recording = null;
6082
+ if (!ports) {
6083
+ this.#store.updateRecording(EMPTY_RECORDING);
6084
+ return;
6085
+ }
6086
+ this.#subscriptions.recording = ports.sync.subscribe((snapshot) => this.#observe(recordingFor(snapshot)));
6087
+ }
6088
+ #observe(next) {
6089
+ const current = this.#store.getSnapshot().recording;
6090
+ if (sameRecording(current, next)) return;
6091
+ this.#store.updateRecording(next);
6092
+ }
6093
+ };
6094
+ var EMPTY_RECORDING = Object.freeze({ current: null });
6095
+ function recordingFor(snapshot) {
6096
+ const recording = (snapshot.optimisticControl ?? snapshot.control)?.recording ?? null;
6097
+ return Object.freeze({ current: recording ? Object.freeze({ ...recording }) : null });
6098
+ }
6099
+ function sameRecording(left, right) {
6100
+ if (left.current === right.current) return true;
6101
+ if (!left.current || !right.current) return false;
6102
+ return left.current.recordingId === right.current.recordingId && left.current.status === right.current.status && left.current.failureCode === right.current.failureCode;
6103
+ }
6104
+ function assertConnectedAndAllowed(snapshot) {
6105
+ if (snapshot.connection.status !== "live" || !snapshot.connection.episode) throw invalidState("Recording commands require a live Episode");
6106
+ if (!snapshot.self.can("manageRecording")) throw new SpaceClientError({ code: "command.rejected", recoverable: false, message: "Recording is not allowed for this Participant" });
6107
+ }
6108
+ function invalidState(message) {
6109
+ return new SpaceClientError({ code: "connection.invalid_state", recoverable: false, message });
6110
+ }
6111
+ function foreign5(operation) {
6112
+ return Effect9.tryPromise({ try: operation, catch: (cause) => cause });
6113
+ }
6114
+
4142
6115
  // src/space-client/whiteboard-controller.ts
4143
- import { Context as Context9, Effect as Effect8, Layer as Layer9 } from "effect";
6116
+ import { Context as Context11, Effect as Effect10, Layer as Layer11 } from "effect";
4144
6117
  var EMPTY_SUMMARY = Object.freeze({ status: "unsubscribed", sceneId: null, revision: null, capabilities: Object.freeze([]), canDraw: false, canClear: false, presenting: false, error: null });
4145
- var WhiteboardControllerService = class extends Context9.Service()("@chalk/client/WhiteboardController") {
6118
+ var WhiteboardControllerService = class extends Context11.Service()("@chalk/client/WhiteboardController") {
4146
6119
  };
4147
- var makeWhiteboardController = (connection, store, create) => Effect8.gen(function* () {
4148
- const context = yield* Effect8.context();
4149
- const controller = yield* Effect8.sync(() => {
6120
+ var makeWhiteboardController = (connection, store, create) => Effect10.gen(function* () {
6121
+ const context = yield* Effect10.context();
6122
+ const controller = yield* Effect10.sync(() => {
4150
6123
  let instance = null;
4151
6124
  const transport = create?.({
4152
- token: () => Effect8.runPromiseWith(context)(connection.getSyncToken()),
6125
+ token: () => Effect10.runPromiseWith(context)(connection.getSyncToken()),
4153
6126
  onSummary: (summary) => instance?.observe(summary)
4154
6127
  }) ?? null;
4155
6128
  instance = new WhiteboardControllerRuntime(connection, store, transport);
4156
6129
  return instance;
4157
6130
  });
4158
- yield* Effect8.addFinalizer(() => Effect8.sync(() => controller.dispose()));
6131
+ yield* Effect10.addFinalizer(() => Effect10.sync(() => controller.dispose()));
4159
6132
  return controller;
4160
6133
  });
4161
6134
  var WhiteboardControllerRuntime = class {
@@ -4217,15 +6190,17 @@ function sameError2(left, right) {
4217
6190
  }
4218
6191
 
4219
6192
  // src/space-client/controller-effects.ts
4220
- var ControllerEffectsService = class extends Context10.Service()("@chalk/client/ControllerEffects") {
6193
+ var ControllerEffectsService = class extends Context12.Service()("@chalk/client/ControllerEffects") {
4221
6194
  };
4222
- var makeControllerEffects = (input) => Effect9.gen(function* () {
6195
+ var makeControllerEffects = (input) => Effect11.gen(function* () {
6196
+ const offlineActions = yield* makeOfflineActions({ connection: input.connection, store: input.store, storage: input.offlineActionsStorage, storageKey: input.offlineActionsStorageKey });
4223
6197
  const media = yield* makeMediaController(input.connection, input.store, input.mediaDeviceSelection, input.episodeDiagnostics);
4224
- const chat = yield* makeChatController({ connection: input.connection, store: input.store, createTransport: input.featureFactories?.createChatFileTransport, apiBaseUrl: input.apiBaseUrl, fetch: input.fetch, episodeDiagnostics: input.episodeDiagnostics });
4225
- const participants = yield* makeParticipantsController(input.connection, input.store, input.episodeDiagnostics);
6198
+ const chat = yield* makeChatController({ connection: input.connection, store: input.store, createTransport: input.featureFactories?.createChatFileTransport, apiBaseUrl: input.apiBaseUrl, fetch: input.fetch, episodeDiagnostics: input.episodeDiagnostics, offline: offlineActions });
6199
+ const participants = yield* makeParticipantsController(input.connection, input.store, input.episodeDiagnostics, offlineActions);
4226
6200
  const reactions = yield* makeReactionsController(input.connection, input.store, input.episodeDiagnostics);
6201
+ const recording = yield* makeRecordingController(input.connection, input.store, input.episodeDiagnostics);
4227
6202
  const whiteboard = yield* makeWhiteboardController(input.connection, input.store, input.featureFactories?.createWhiteboardClient);
4228
- return { media, chat, participants, reactions, whiteboard };
6203
+ return { media, offline: offlineActions, chat, participants, reactions, recording, whiteboard };
4229
6204
  });
4230
6205
 
4231
6206
  // src/space-client/media-device-selection.ts
@@ -4280,7 +6255,7 @@ function requireDeviceId(deviceId) {
4280
6255
  }
4281
6256
 
4282
6257
  // src/space-client/episode-diagnostic-runtime.ts
4283
- import { encodedEventSize, getActionContract, parseDiagnosticEventDraft, redactDiagnosticAttributes } from "@q9labsai/diagnostics-contracts";
6258
+ import { encodedEventSize, getActionContract, parseDiagnosticEventDraft as parseDiagnosticEventDraft2, redactDiagnosticAttributes as redactDiagnosticAttributes2 } from "@q9labsai/diagnostics-contracts";
4284
6259
  var DEFAULT_RING_EVENTS = 256;
4285
6260
  var DEFAULT_RING_BYTES = 256 * 1024;
4286
6261
  var DEFAULT_RING_AGE_MS = 2 * 60 * 1e3;
@@ -4388,6 +6363,15 @@ var EpisodeDiagnosticRuntime = class {
4388
6363
  activeContext() {
4389
6364
  return this.#enabled() ? this.context() : void 0;
4390
6365
  }
6366
+ /** Internal Feedback seam. It returns only a short-lived verified credential to the private transport. */
6367
+ feedbackCredentialUnsafe() {
6368
+ if (!this.#enabled() || !this.#credential) return null;
6369
+ return Object.freeze({ ...this.#credential });
6370
+ }
6371
+ feedbackAvailabilityUnsafe() {
6372
+ if (this.#disposed) return "disposed";
6373
+ return this.#enabled() ? "available" : "disabled";
6374
+ }
4391
6375
  startOperation(name, attributes, parentOperationRef, producerOperationRef, correlationInput) {
4392
6376
  if (!this.#enabled()) return void 0;
4393
6377
  const credentialGeneration = this.#credential?.generation;
@@ -4453,7 +6437,7 @@ var EpisodeDiagnosticRuntime = class {
4453
6437
  });
4454
6438
  }
4455
6439
  #draft(input) {
4456
- return parseDiagnosticEventDraft(
6440
+ return parseDiagnosticEventDraft2(
4457
6441
  buildDraftInput(input, {
4458
6442
  eventId: safeId(this.#createId()),
4459
6443
  producerSequence: ++this.#sequence,
@@ -4764,7 +6748,7 @@ function buildDraftInput(input, context) {
4764
6748
  expectation: expectationFor(contract, checkpoint),
4765
6749
  correlation: optionalCorrelation(correlation),
4766
6750
  release: context.release,
4767
- attributes: redactDiagnosticAttributes(input.attributes).attributes
6751
+ attributes: redactDiagnosticAttributes2(input.attributes).attributes
4768
6752
  };
4769
6753
  }
4770
6754
  function eventReferences(input) {
@@ -4885,9 +6869,125 @@ function renderRegistry() {
4885
6869
  return target[REGISTRY];
4886
6870
  }
4887
6871
 
6872
+ // src/feedback/controller.ts
6873
+ var DEFAULT_SDK = Object.freeze({ client: "@q9labsai/chalk-client" });
6874
+ var DEFAULT_PLATFORM = Object.freeze({ kind: "web" });
6875
+ function createFeedbackController(context) {
6876
+ const transport = createFeedbackTransport({
6877
+ baseUrl: context.apiBaseUrl,
6878
+ fetch: context.fetch,
6879
+ credential: context.diagnosticCredential,
6880
+ telemetry: () => context.telemetry ?? context.diagnosticContext()
6881
+ });
6882
+ let disposed = false;
6883
+ const ensureActive = () => {
6884
+ if (disposed) throw new FeedbackTransportError({ code: "unavailable", recoverable: false, message: "Feedback has been disposed." });
6885
+ };
6886
+ const prepare = async (input = {}) => {
6887
+ ensureActive();
6888
+ const collectionInput = evidenceInput(input.evidence);
6889
+ const screenshot = await resolveScreenshot(input.screenshot_provider ?? input.screenshotProvider);
6890
+ ensureActive();
6891
+ const initialInput = { ...collectionInput, screenshot };
6892
+ const idempotencyKey = idempotencyKeyFor(context.createId);
6893
+ const preparedEvidence = collectFeedbackEvidenceFromContext(initialInput, context);
6894
+ const preparedScreenshot = capturedScreenshot(screenshot) ? screenshotPayload(screenshot).screenshot : void 0;
6895
+ let cachedRequest;
6896
+ const prepared = {
6897
+ idempotency_key: idempotencyKey,
6898
+ evidence: preparedEvidence,
6899
+ ...preparedScreenshot ? { screenshot: preparedScreenshot } : {},
6900
+ collect: (nextInput) => {
6901
+ ensureActive();
6902
+ return collectFeedbackEvidenceFromContext(nextInput ?? initialInput, context);
6903
+ },
6904
+ send: async ({ category, message }) => {
6905
+ ensureActive();
6906
+ const normalizedMessage = assertFeedbackMessage(message);
6907
+ const request = cachedRequest ?? parseFeedbackReportRequest({
6908
+ schema_version: FEEDBACK_REQUEST_SCHEMA_VERSION,
6909
+ category,
6910
+ message: normalizedMessage,
6911
+ source: input.source ?? context.source,
6912
+ evidence: preparedEvidence,
6913
+ ...preparedScreenshot ? { screenshot: preparedScreenshot } : {}
6914
+ });
6915
+ cachedRequest ??= request;
6916
+ return transport.send({ request, idempotencyKey });
6917
+ }
6918
+ };
6919
+ return prepared;
6920
+ };
6921
+ const send = async (input) => {
6922
+ ensureActive();
6923
+ const source = input.source ?? context.source;
6924
+ const evidenceInput2 = input.evidence ?? defaultEvidenceInput();
6925
+ const screenshot = input.screenshot ?? evidenceInput2.screenshot;
6926
+ return sendRequest({ category: input.category, message: input.message, source, evidence: { ...evidenceInput2, ...screenshot ? { screenshot } : {} }, idempotencyKey: input.idempotency_key ?? input.idempotencyKey ?? idempotencyKeyFor(context.createId) });
6927
+ };
6928
+ const sendRequest = async (input) => {
6929
+ ensureActive();
6930
+ const evidence = collectFeedbackEvidenceFromContext(input.evidence, context);
6931
+ const request = parseFeedbackReportRequest({ schema_version: FEEDBACK_REQUEST_SCHEMA_VERSION, category: input.category, message: assertFeedbackMessage(input.message), source: input.source ?? context.source, evidence, ...screenshotPayload(input.evidence.screenshot) });
6932
+ return transport.send({ request, idempotencyKey: input.idempotencyKey });
6933
+ };
6934
+ return {
6935
+ prepare,
6936
+ send,
6937
+ dispose: () => {
6938
+ disposed = true;
6939
+ }
6940
+ };
6941
+ }
6942
+ function evidenceInput(input) {
6943
+ const base = { sdk: input?.sdk ?? DEFAULT_SDK, platform: input?.platform ?? DEFAULT_PLATFORM };
6944
+ if (!input) return base;
6945
+ return { ...base, ...optionalEvidenceInput(input) };
6946
+ }
6947
+ function optionalEvidenceInput(input) {
6948
+ return {
6949
+ ...input.app ? { app: input.app } : {},
6950
+ ...input.local_state ? { local_state: input.local_state } : {},
6951
+ ...input.cookies ? { cookies: input.cookies } : {}
6952
+ };
6953
+ }
6954
+ function defaultEvidenceInput() {
6955
+ return { sdk: DEFAULT_SDK, platform: DEFAULT_PLATFORM };
6956
+ }
6957
+ async function resolveScreenshot(provider) {
6958
+ if (!provider) return { state: "unavailable", failure_code: "unsupported" };
6959
+ let timeout;
6960
+ try {
6961
+ return await Promise.race([
6962
+ provider(),
6963
+ new Promise((resolve) => {
6964
+ timeout = globalThis.setTimeout(() => resolve({ state: "unavailable", failure_code: "capture_failed" }), 5e3);
6965
+ })
6966
+ ]);
6967
+ } catch {
6968
+ return { state: "unavailable", failure_code: "capture_failed" };
6969
+ } finally {
6970
+ globalThis.clearTimeout(timeout);
6971
+ }
6972
+ }
6973
+ function capturedScreenshot(input) {
6974
+ return input.state === "captured" || input.state === "partial" ? input : void 0;
6975
+ }
6976
+ function screenshotPayload(input) {
6977
+ if (!input) return {};
6978
+ const screenshot = capturedScreenshot(input);
6979
+ return screenshot ? { screenshot: { schema_version: "FeedbackScreenshot/v1", mime_type: screenshot.mime_type, width: screenshot.width, height: screenshot.height, captured_at: screenshot.captured_at, data_base64: screenshot.data_base64 } } : {};
6980
+ }
6981
+ function idempotencyKeyFor(createId) {
6982
+ const generated = createId();
6983
+ if (/^[A-Za-z0-9_-]{16,128}$/u.test(generated)) return generated;
6984
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
6985
+ return `feedback-${Date.now()}-${Math.random().toString(36).slice(2, 18)}`;
6986
+ }
6987
+
4888
6988
  // src/space-client/core.ts
4889
6989
  var syncClientsByDependencies = /* @__PURE__ */ new WeakMap();
4890
- var SpaceClientCoreService = class extends Context11.Service()("@chalk/client/SpaceClientCore") {
6990
+ var SpaceClientCoreService = class extends Context13.Service()("@chalk/client/SpaceClientCore") {
4891
6991
  };
4892
6992
  var makeSpaceClientCoreLayer = (options, platform = {}) => {
4893
6993
  const apiBaseUrl = normalizedBaseUrl2(platform.apiBaseUrl ?? options.baseUrl ?? "https://api.chalkmeet.com");
@@ -4900,7 +7000,7 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
4900
7000
  now: baseDependencies.clock.now,
4901
7001
  setTimeout: baseDependencies.clock.setTimeout,
4902
7002
  clearTimeout: baseDependencies.clock.clearTimeout,
4903
- release: { id: "chalk-client@4.1.15" }
7003
+ release: { id: "chalk-client@4.1.17" }
4904
7004
  });
4905
7005
  const diagnosticSyncClients = /* @__PURE__ */ new Set();
4906
7006
  const dependencies = {
@@ -4922,6 +7022,7 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
4922
7022
  initialCameraEnabled: platform.initialCameraEnabled,
4923
7023
  telemetry: platform.telemetry,
4924
7024
  recordRtcSummary: platform.recordRtcSummary,
7025
+ recordReconnect: platform.recordReconnect,
4925
7026
  dependencies,
4926
7027
  diagnostics: {
4927
7028
  onEvent: (event) => {
@@ -4930,30 +7031,49 @@ var makeSpaceClientCoreLayer = (options, platform = {}) => {
4930
7031
  }
4931
7032
  }
4932
7033
  });
4933
- return makeSpaceClientCoreLayerFromServices(options, platform).pipe(Layer11.provideMerge(Layer11.mergeAll(makeSpaceStoreLayer, lifecycle, makeConnectionPlatformLayer(dependencies), liveClock(dependencies))));
7034
+ return makeSpaceClientCoreLayerFromServices(options, platform).pipe(Layer13.provideMerge(Layer13.mergeAll(makeSpaceStoreLayer, lifecycle, makeConnectionPlatformLayer(dependencies), liveClock(dependencies))));
4934
7035
  };
4935
- var makeSpaceClientCoreLayerFromServices = (options, platform = {}) => Layer11.effect(
7036
+ var makeSpaceClientCoreLayerFromServices = (options, platform = {}) => Layer13.effect(
4936
7037
  SpaceClientCoreService,
4937
- Effect10.gen(function* () {
4938
- const store = yield* Effect10.service(SpaceStoreService);
4939
- const lifecycle = yield* Effect10.service(ConnectionLifecycleService);
4940
- const dependencies = yield* Effect10.service(ConnectionPlatformService);
7038
+ Effect12.gen(function* () {
7039
+ const store = yield* Effect12.service(SpaceStoreService);
7040
+ const lifecycle = yield* Effect12.service(ConnectionLifecycleService);
7041
+ const dependencies = yield* Effect12.service(ConnectionPlatformService);
4941
7042
  const space = options.space.trim();
4942
- if (space.length === 0) return yield* Effect10.die(new TypeError("A Space slug is required"));
4943
- if (typeof options.getAccess !== "function") return yield* Effect10.die(new TypeError("getAccess is required"));
7043
+ if (space.length === 0) return yield* Effect12.die(new TypeError("A Space slug is required"));
7044
+ if (typeof options.getAccess !== "function") return yield* Effect12.die(new TypeError("getAccess is required"));
4944
7045
  const apiBaseUrl = normalizedBaseUrl2(platform.apiBaseUrl ?? options.baseUrl ?? "https://api.chalkmeet.com");
4945
7046
  const selection = new MediaDeviceSelection(dependencies.mediaDevices);
4946
7047
  const episodeDiagnostics = episodeDiagnosticsForDependencies(dependencies);
4947
7048
  if (episodeDiagnostics) registerEpisodeDiagnosticConnection(lifecycle, episodeDiagnostics);
4948
- const controllers = yield* makeControllerEffects({ apiBaseUrl, connection: lifecycle, store, mediaDeviceSelection: selection, featureFactories: dependencies, fetch: platform.fetch, episodeDiagnostics });
4949
- return yield* Effect10.acquireRelease(
4950
- Effect10.sync(() => new SpaceClientCore(lifecycle, selection, store, controllers, episodeDiagnostics, dependencies, syncClientsByDependencies.get(dependencies))),
4951
- (core) => Effect10.sync(() => core.dispose())
7049
+ const controllers = yield* makeControllerEffects({
7050
+ offlineActionsStorage: options.offlineActionsStorage,
7051
+ offlineActionsStorageKey: `chalk:offline-actions:${JSON.stringify([apiBaseUrl, space])}`,
7052
+ apiBaseUrl,
7053
+ connection: lifecycle,
7054
+ store,
7055
+ mediaDeviceSelection: selection,
7056
+ featureFactories: dependencies,
7057
+ fetch: platform.fetch,
7058
+ episodeDiagnostics
7059
+ });
7060
+ return yield* Effect12.acquireRelease(
7061
+ Effect12.sync(
7062
+ () => new SpaceClientCore(lifecycle, selection, store, controllers, episodeDiagnostics, dependencies, syncClientsByDependencies.get(dependencies), {
7063
+ apiBaseUrl,
7064
+ fetch: platform.fetch,
7065
+ createId: platform.randomUUID ?? dependencies.createId ?? lifecycle.createId,
7066
+ source: platform.feedbackSource ?? "embedded",
7067
+ telemetry: platform.telemetry
7068
+ })
7069
+ ),
7070
+ (core) => Effect12.sync(() => core.dispose())
4952
7071
  );
4953
7072
  })
4954
7073
  );
4955
7074
  var SpaceClientCore = class {
4956
7075
  controllers;
7076
+ feedback;
4957
7077
  #connection;
4958
7078
  #store;
4959
7079
  #episodeDiagnostics;
@@ -4962,24 +7082,46 @@ var SpaceClientCore = class {
4962
7082
  #episodeDiagnosticTracks = /* @__PURE__ */ new Map();
4963
7083
  #handlers = { participantJoined: /* @__PURE__ */ new Set(), participantLeft: /* @__PURE__ */ new Set(), episodeEnded: /* @__PURE__ */ new Set(), screenShareStarted: /* @__PURE__ */ new Set(), screenShareStopped: /* @__PURE__ */ new Set(), error: /* @__PURE__ */ new Set() };
4964
7084
  #disposed = false;
7085
+ #episodeEnded = false;
4965
7086
  #previous;
4966
7087
  #unsubscribeConnection = null;
4967
7088
  #unsubscribeStore = null;
4968
7089
  #leaveOperation;
4969
7090
  #participantJoinOperation;
4970
7091
  #syncConnectOperation;
4971
- constructor(connection, _selection, store, controllers, episodeDiagnostics, episodeDiagnosticDependencies, episodeDiagnosticSyncClients) {
7092
+ constructor(connection, _selection, store, controllers, episodeDiagnostics, episodeDiagnosticDependencies, episodeDiagnosticSyncClients, feedback) {
4972
7093
  this.#connection = connection;
4973
7094
  this.#store = store;
4974
7095
  this.controllers = controllers;
4975
7096
  this.#episodeDiagnostics = episodeDiagnostics;
4976
7097
  this.#episodeDiagnosticDependencies = episodeDiagnosticDependencies;
4977
7098
  this.#episodeDiagnosticSyncClients = episodeDiagnosticSyncClients;
7099
+ this.feedback = createFeedbackController({
7100
+ apiBaseUrl: feedback?.apiBaseUrl ?? "https://api.chalkmeet.com",
7101
+ fetch: feedback?.fetch,
7102
+ createId: feedback?.createId ?? connection.createId,
7103
+ now: connection.nowUnsafe,
7104
+ source: feedback?.source ?? "embedded",
7105
+ telemetry: feedback?.telemetry,
7106
+ diagnosticContext: () => episodeDiagnostics?.activeContext(),
7107
+ connection: connection.getSnapshot,
7108
+ diagnosticCredential: () => episodeDiagnostics?.feedbackCredentialUnsafe() ?? null,
7109
+ diagnosticAvailability: () => episodeDiagnostics?.feedbackAvailabilityUnsafe() ?? "unavailable",
7110
+ diagnosticSnapshot: () => {
7111
+ const inspected = episodeDiagnostics?.inspect();
7112
+ return { dropped: inspected?.dropped ?? 0, events: inspected?.ring ?? [] };
7113
+ }
7114
+ });
4978
7115
  this.#store.updateConnection(connection.getSnapshot());
4979
7116
  this.#previous = this.#store.getSnapshot();
4980
7117
  this.#unsubscribeStore = this.#store.subscribe(() => this.#publishStore());
4981
7118
  this.#unsubscribeConnection = connection.subscribe(() => {
4982
- this.#store.updateConnection(connection.getSnapshot());
7119
+ const snapshot = connection.getSnapshot();
7120
+ const episode = snapshot.episode ?? this.#store.getSnapshot().connection.episode;
7121
+ this.#store.updateConnection(snapshot);
7122
+ const newlyEnded = snapshot.episodeEnded && !this.#episodeEnded;
7123
+ this.#episodeEnded = snapshot.episodeEnded ?? false;
7124
+ if (newlyEnded) this.#emit("episodeEnded", { episode });
4983
7125
  this.#publishConnectionDiagnostics();
4984
7126
  });
4985
7127
  this.#publishConnectionDiagnostics();
@@ -4992,47 +7134,47 @@ var SpaceClientCore = class {
4992
7134
  join(options = {}) {
4993
7135
  this.#participantJoinOperation = this.#episodeDiagnostics?.startOperation("participant.join");
4994
7136
  this.#syncConnectOperation = this.#episodeDiagnostics?.startOperation("sync.connect");
4995
- return Effect10.sync(() => this.#requireActive()).pipe(
4996
- Effect10.tap(() => Effect10.sync(() => this.controllers.media.configure(options))),
4997
- Effect10.andThen(this.#connection.configureJoin(options)),
4998
- Effect10.andThen(this.#connection.join()),
4999
- Effect10.andThen(options.displayName === void 0 ? Effect10.void : this.controllers.participants.renameSelf(options.displayName)),
5000
- Effect10.tap(
5001
- () => Effect10.sync(() => {
7137
+ return Effect12.sync(() => this.#requireActive()).pipe(
7138
+ Effect12.tap(() => Effect12.sync(() => this.controllers.media.configure(options))),
7139
+ Effect12.andThen(this.#connection.configureJoin(options)),
7140
+ Effect12.andThen(this.#connection.join()),
7141
+ Effect12.andThen(options.displayName === void 0 ? Effect12.void : this.controllers.participants.renameSelf(options.displayName)),
7142
+ Effect12.tap(
7143
+ () => Effect12.sync(() => {
5002
7144
  this.#participantJoinOperation?.succeed();
5003
7145
  this.#syncConnectOperation?.succeed();
5004
7146
  })
5005
7147
  ),
5006
- Effect10.tapError(
5007
- () => Effect10.sync(() => {
7148
+ Effect12.tapError(
7149
+ () => Effect12.sync(() => {
5008
7150
  this.#participantJoinOperation?.fail("join_failed");
5009
7151
  this.#syncConnectOperation?.fail("connect_failed");
5010
7152
  })
5011
7153
  ),
5012
- Effect10.mapError(normalizeClientError)
7154
+ Effect12.catchTag("ConnectionLifecycleFailure", (failure3) => Effect12.fail(failure3.code === "episode_ended" ? new SpaceClientError({ code: "episode.ended", recoverable: failure3.recoverable, message: failure3.message }) : failure3)),
7155
+ Effect12.mapError(normalizeClientError)
5013
7156
  );
5014
7157
  }
5015
7158
  leave() {
5016
7159
  this.#leaveOperation = this.#episodeDiagnostics?.startOperation("participant.leave");
5017
7160
  return this.#connection.leave().pipe(
5018
- Effect10.tap(() => Effect10.sync(() => this.#leaveOperation?.succeed())),
5019
- Effect10.tapError(() => Effect10.sync(() => this.#leaveOperation?.fail("leave_failed"))),
5020
- Effect10.mapError(normalizeClientError)
7161
+ Effect12.tap(() => Effect12.sync(() => this.#leaveOperation?.succeed())),
7162
+ Effect12.tapError(() => Effect12.sync(() => this.#leaveOperation?.fail("leave_failed"))),
7163
+ Effect12.mapError(normalizeClientError)
5021
7164
  );
5022
7165
  }
5023
7166
  endEpisode() {
5024
- return trackedEffect(
5025
- this.#episodeDiagnostics,
5026
- "episode.end.authorized",
5027
- this.#connection.runCommand(({ sync }) => foreign5(() => sync.endEpisode()).pipe(Effect10.tap(() => this.#connection.confirmEpisodeEnded)))
5028
- ).pipe(Effect10.asVoid, Effect10.mapError(normalizeClientError));
7167
+ return trackedEffect(this.#episodeDiagnostics, "episode.end.authorized", this.#connection.runCommand(({ sync }) => foreign6(() => sync.endEpisode()).pipe(Effect12.tap(() => this.#connection.confirmEpisodeEnded))).pipe(Effect12.tap(() => this.#connection.leave()))).pipe(
7168
+ Effect12.asVoid,
7169
+ Effect12.mapError(normalizeClientError)
7170
+ );
5029
7171
  }
5030
7172
  extendEpisode(minutes) {
5031
7173
  return trackedEffect(
5032
7174
  this.#episodeDiagnostics,
5033
7175
  "episode.deadline.extend",
5034
- this.#connection.runCommand(({ sync }) => foreign5(() => sync.extendEpisode(minutes)))
5035
- ).pipe(Effect10.asVoid, Effect10.mapError(normalizeClientError));
7176
+ this.#connection.runCommand(({ sync }) => foreign6(() => sync.extendEpisode(minutes)))
7177
+ ).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
5036
7178
  }
5037
7179
  on(event, handler) {
5038
7180
  this.#handlers[event].add(handler);
@@ -5045,6 +7187,7 @@ var SpaceClientCore = class {
5045
7187
  this.#unsubscribeStore?.();
5046
7188
  this.#unsubscribeConnection = null;
5047
7189
  this.#unsubscribeStore = null;
7190
+ this.feedback.dispose();
5048
7191
  this.#disposeDiagnostics();
5049
7192
  this.#clearHandlers();
5050
7193
  }
@@ -5112,7 +7255,6 @@ var SpaceClientCore = class {
5112
7255
  }
5113
7256
  #emitChanges(previous, next) {
5114
7257
  this.#emitParticipantChanges(previous, next);
5115
- this.#emitEpisodeEnd(previous, next);
5116
7258
  this.#emitScreenShareChanges(previous, next);
5117
7259
  this.#emitErrorChange(previous, next);
5118
7260
  }
@@ -5122,9 +7264,6 @@ var SpaceClientCore = class {
5122
7264
  for (const participant of after.values()) if (!before.has(participant.participantId)) this.#emit("participantJoined", { participant });
5123
7265
  for (const participant of before.values()) if (!after.has(participant.participantId)) this.#emit("participantLeft", { participant });
5124
7266
  }
5125
- #emitEpisodeEnd(previous, next) {
5126
- if (previous.connection.lastError?.code !== "episode.ended" && next.connection.lastError?.code === "episode.ended") this.#emit("episodeEnded", { episode: previous.connection.episode ?? next.connection.episode });
5127
- }
5128
7267
  #emitScreenShareChanges(previous, next) {
5129
7268
  const previousShares = screenShares(previous);
5130
7269
  const nextShares = screenShares(next);
@@ -5146,25 +7285,25 @@ var SpaceClientCore = class {
5146
7285
  }
5147
7286
  };
5148
7287
  function liveClock(dependencies) {
5149
- return Layer11.succeed(Clock5.Clock, {
7288
+ return Layer13.succeed(Clock5.Clock, {
5150
7289
  currentTimeMillisUnsafe: dependencies.clock.now,
5151
- currentTimeMillis: Effect10.sync(dependencies.clock.now),
7290
+ currentTimeMillis: Effect12.sync(dependencies.clock.now),
5152
7291
  currentTimeNanosUnsafe: () => BigInt(dependencies.clock.now()) * 1000000n,
5153
- currentTimeNanos: Effect10.sync(() => BigInt(dependencies.clock.now()) * 1000000n),
5154
- sleep: (duration) => Effect10.callback((resume) => {
5155
- const handle = dependencies.clock.setTimeout(() => resume(Effect10.void), Duration2.toMillis(duration));
5156
- return Effect10.sync(() => dependencies.clock.clearTimeout(handle));
7292
+ currentTimeNanos: Effect12.sync(() => BigInt(dependencies.clock.now()) * 1000000n),
7293
+ sleep: (duration) => Effect12.callback((resume) => {
7294
+ const handle = dependencies.clock.setTimeout(() => resume(Effect12.void), Duration2.toMillis(duration));
7295
+ return Effect12.sync(() => dependencies.clock.clearTimeout(handle));
5157
7296
  })
5158
7297
  });
5159
7298
  }
5160
- function foreign5(operation) {
5161
- return Effect10.tryPromise({ try: operation, catch: (cause) => cause });
7299
+ function foreign6(operation) {
7300
+ return Effect12.tryPromise({ try: operation, catch: (cause) => cause });
5162
7301
  }
5163
7302
  function trackedEffect(diagnostics, name, effect) {
5164
7303
  const operation = diagnostics?.startOperation(name);
5165
7304
  return effect.pipe(
5166
- Effect10.tap(() => Effect10.sync(() => operation?.succeed())),
5167
- Effect10.tapError(() => Effect10.sync(() => operation?.fail("operation_failed")))
7305
+ Effect12.tap(() => Effect12.sync(() => operation?.succeed())),
7306
+ Effect12.tapError(() => Effect12.sync(() => operation?.fail("operation_failed")))
5168
7307
  );
5169
7308
  }
5170
7309
  async function resolveAccessGrant(input) {
@@ -5186,7 +7325,7 @@ function diagnosticAccessOperation(request) {
5186
7325
  }
5187
7326
  async function fetchAccessGrant2(input) {
5188
7327
  if (input.platform.connectionAccess) return input.platform.connectionAccess(input.request);
5189
- return input.options.getAccess({ space: input.options.space.trim(), reason: accessReason(input.request?.reason) });
7328
+ return input.options.getAccess({ space: input.options.space.trim(), reason: accessReason(input.request?.reason), replaceMediaConnection: input.request?.replaceMediaConnection ?? false, signal: input.request?.signal });
5190
7329
  }
5191
7330
  function accessReason(reason) {
5192
7331
  if (reason === void 0 || reason === "join") return "join";
@@ -5225,17 +7364,17 @@ function sameDiagnosticTrack(left, right) {
5225
7364
  }
5226
7365
 
5227
7366
  // src/space-client/space-client.ts
5228
- import { Effect as Effect12, ManagedRuntime } from "effect";
7367
+ import { Effect as Effect14, ManagedRuntime } from "effect";
5229
7368
 
5230
7369
  // src/space-client/promise-facade.ts
5231
- import { Effect as Effect11 } from "effect";
7370
+ import { Effect as Effect13 } from "effect";
5232
7371
  function toPromiseController(runtime, controller) {
5233
7372
  const projected = {};
5234
7373
  for (const [name, command] of Object.entries(controller)) {
5235
7374
  if (name === "configure" || name === "dispose") continue;
5236
7375
  projected[name] = (...args) => {
5237
7376
  const result = command(...args);
5238
- return Effect11.isEffect(result) ? runtime.runPromise(result) : result;
7377
+ return Effect13.isEffect(result) ? runtime.runPromise(result) : result;
5239
7378
  };
5240
7379
  }
5241
7380
  return projected;
@@ -5243,24 +7382,30 @@ function toPromiseController(runtime, controller) {
5243
7382
 
5244
7383
  // src/space-client/space-client.ts
5245
7384
  var PromiseSpaceClient = class {
7385
+ feedback;
5246
7386
  media;
5247
7387
  chat;
5248
7388
  participants;
7389
+ offline;
5249
7390
  reactions;
7391
+ recording;
5250
7392
  whiteboard;
5251
7393
  #core;
5252
7394
  #runtime;
5253
7395
  #disposed = false;
5254
7396
  constructor(options, platform) {
5255
7397
  this.#runtime = ManagedRuntime.make(makeSpaceClientCoreLayer(options, platform));
5256
- this.#core = this.#runtime.runSync(Effect12.service(SpaceClientCoreService));
7398
+ this.#core = this.#runtime.runSync(Effect14.service(SpaceClientCoreService));
5257
7399
  const controllers = this.#core.controllers;
7400
+ this.feedback = this.#core.feedback;
5258
7401
  this.media = toPromiseController(this.#runtime, controllers.media);
5259
7402
  const chat = toPromiseController(this.#runtime, controllers.chat);
5260
- const { upload, url, ...chatCommands } = chat;
5261
- this.chat = { ...chatCommands, files: { upload, url } };
7403
+ const { upload, url, resolveUrl, ...chatCommands } = chat;
7404
+ this.chat = { ...chatCommands, files: { upload, url, resolveUrl } };
7405
+ this.offline = toPromiseController(this.#runtime, controllers.offline);
5262
7406
  this.participants = toPromiseController(this.#runtime, controllers.participants);
5263
7407
  this.reactions = toPromiseController(this.#runtime, controllers.reactions);
7408
+ this.recording = toPromiseController(this.#runtime, controllers.recording);
5264
7409
  this.whiteboard = toPromiseController(this.#runtime, controllers.whiteboard);
5265
7410
  }
5266
7411
  join = (options) => this.#runtime.runPromise(this.#core.join(options));
@@ -5293,11 +7438,11 @@ export {
5293
7438
  ConnectionPlatformService,
5294
7439
  makeConnectionPlatformLayer,
5295
7440
  makeFakeConnectionPlatformLayer,
7441
+ ChalkWhiteboardV1Error,
5296
7442
  CHALK_CHAT_ATTACHMENT_LIMITS,
5297
7443
  CHALK_CHAT_ATTACHMENT_MIME_TYPES,
5298
7444
  ChalkChatFileError,
5299
7445
  createChalkChatFileHttpTransport,
5300
- ChalkWhiteboardV1Error,
5301
7446
  createChalkWhiteboardV1FileHttpTransport,
5302
7447
  encodeWhiteboardV1ClientFrame,
5303
7448
  decodeWhiteboardV1ServerFrame,
@@ -5312,6 +7457,35 @@ export {
5312
7457
  SpaceStoreService,
5313
7458
  makeSpaceStoreLayer,
5314
7459
  makeFakeSpaceStoreLayer,
7460
+ FEEDBACK_EVIDENCE_SCHEMA_VERSION,
7461
+ FEEDBACK_REQUEST_SCHEMA_VERSION,
7462
+ FEEDBACK_RECEIPT_SCHEMA_VERSION,
7463
+ FEEDBACK_SCREENSHOT_SCHEMA_VERSION,
7464
+ FEEDBACK_LOCAL_STATE_REGISTRY_VERSION,
7465
+ FEEDBACK_COOKIE_REGISTRY_VERSION,
7466
+ FEEDBACK_MAX_MESSAGE_BYTES,
7467
+ FEEDBACK_MAX_EVIDENCE_BYTES,
7468
+ FEEDBACK_MAX_REQUEST_BYTES,
7469
+ FEEDBACK_MAX_SCREENSHOT_BYTES,
7470
+ FEEDBACK_MAX_SCREENSHOT_WIDTH,
7471
+ FEEDBACK_MAX_SCREENSHOT_HEIGHT,
7472
+ FEEDBACK_MAX_TELEMETRY_EVENTS,
7473
+ FEEDBACK_MAX_DIAGNOSTIC_EVENTS,
7474
+ FEEDBACK_MAX_LOCAL_STATE_ENTRIES,
7475
+ FEEDBACK_MAX_COOKIE_ENTRIES,
7476
+ FeedbackValidationError,
7477
+ validateFeedbackReportRequest,
7478
+ parseFeedbackReportRequest,
7479
+ validateFeedbackEvidence,
7480
+ parseFeedbackEvidence,
7481
+ validateFeedbackScreenshot,
7482
+ parseFeedbackScreenshot,
7483
+ assertFeedbackReceipt,
7484
+ assertFeedbackMessage,
7485
+ collectFeedbackEvidence,
7486
+ collectLocalState,
7487
+ collectCookies,
7488
+ FeedbackTransportError,
5315
7489
  SpaceClientCoreService,
5316
7490
  makeSpaceClientCoreLayer,
5317
7491
  makeSpaceClientCoreLayerFromServices,