@alexkroman1/aai-ui 0.14.1 → 0.14.2

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.
@@ -646,6 +646,218 @@ function closeFailure(event, socketErrored) {
646
646
  return socketErrored ? "WebSocket connection error" : null;
647
647
  }
648
648
  //#endregion
649
+ //#region src/session/handshake.ts
650
+ /** What the session reports once the budget below is spent. */
651
+ const HANDSHAKE_ERROR = {
652
+ code: "connection",
653
+ message: "Agent did not complete the session handshake",
654
+ fatal: false
655
+ };
656
+ //#endregion
657
+ //#region src/session/connection.ts
658
+ /**
659
+ * One browser session's CONNECTION — the socket, the handshake deadline over
660
+ * it, and the server's idle retirement — as a statechart.
661
+ *
662
+ * `link` is `closed → open`, and `open` is `dialing → awaitingHandshake → live`:
663
+ *
664
+ * - **The socket belongs to `open`.** Its entry dials and attaches the
665
+ * listeners; its exit is the ONE teardown (detach, release the pre-connect
666
+ * mic, tear the audio path down, close the socket). A hang-up, a terminal
667
+ * close, an exhausted handshake and a fresh `connect()` all leave `open`, so
668
+ * none of them restates it, and exit actions run before the transition's, so
669
+ * the teardown always precedes the snapshot write reporting the end.
670
+ * - **The handshake deadline is an `after` on `awaitingHandshake`** (see
671
+ * `session/handshake.ts` for why there is one). Leaving the state disarms it,
672
+ * so a `config` frame, a close or a teardown cannot leave it armed against
673
+ * the next attempt — there is no timer to clear by hand on abort.
674
+ * - **partysocket owns the reconnect backoff.** A close that partysocket will
675
+ * retry goes back to `dialing` and waits for its next `open`; this machine
676
+ * never schedules an attempt. The deadline forces one only for a socket
677
+ * partysocket thinks is healthy (`forceReconnect`).
678
+ *
679
+ * `retired` is a second region because it outlives a socket: the server's
680
+ * `session.timedOut` marks the close that follows as EXPECTED, so it is not
681
+ * retried. Without it the automatic reconnect would re-open the session the
682
+ * server just reclaimed — a tab left open would cycle forever and the guest
683
+ * would never see zero sessions, which is the point of the timeout. Only a
684
+ * fresh `connect()` (the user asking again) clears it.
685
+ *
686
+ * The machine owns WHEN; every HOW — the socket, the snapshot, the audio path —
687
+ * is an injected {@link ConnectionEffects} call, as in `session/audio-state.ts`.
688
+ */
689
+ const connectionMachine = setup({
690
+ types: {},
691
+ delays: { HANDSHAKE_TIMEOUT: 1e4 },
692
+ guards: {
693
+ /**
694
+ * partysocket will retry this close, and nothing says it must not: a FATAL
695
+ * error is not retryable by construction, nor is an idle retirement.
696
+ */
697
+ mayReconnect: and([({ context, event }) => event.type === "DROPPED" && event.reconnecting && !context.effects.fatal(), not(stateIn({ retired: "yes" }))]),
698
+ /** Budget left, and a socket that can re-dial at all. */
699
+ mayRedial: ({ context }) => context.timeouts + 1 < 3 && context.effects.canRedial()
700
+ },
701
+ actions: {
702
+ spend: assign({ timeouts: ({ context }) => context.timeouts + 1 }),
703
+ /** A socket error inside a retry cycle is not terminal. */
704
+ forgetError: assign({ errored: false }),
705
+ retrying: ({ context }) => context.effects.retrying(),
706
+ opened: ({ context }) => context.effects.opened(),
707
+ dial: ({ context, event }) => context.link.dial(event.type === "DIAL" ? event.signal : void 0),
708
+ /** The ONE teardown: every way out of `open` runs it, before anything is reported. */
709
+ teardown: ({ context }) => context.link.teardown(),
710
+ reportClose: ({ context, event }) => {
711
+ if (event.type === "DROPPED") context.effects.closed(closeFailure(event.event, context.errored));
712
+ }
713
+ }
714
+ }).createMachine({
715
+ id: "connection",
716
+ type: "parallel",
717
+ context: ({ input }) => ({
718
+ ...input,
719
+ timeouts: 0,
720
+ errored: false
721
+ }),
722
+ states: {
723
+ link: {
724
+ initial: "closed",
725
+ on: {
726
+ DIAL: {
727
+ target: ".open",
728
+ reenter: true
729
+ },
730
+ HANG_UP: ".closed"
731
+ },
732
+ states: {
733
+ closed: {},
734
+ open: {
735
+ entry: [assign({
736
+ timeouts: 0,
737
+ errored: false
738
+ }), "dial"],
739
+ exit: "teardown",
740
+ initial: "dialing",
741
+ on: {
742
+ ERRORED: { actions: assign({ errored: true }) },
743
+ CONFIGURED: {
744
+ target: ".live",
745
+ actions: [assign({ timeouts: 0 }), ({ context, event }) => context.effects.configured(event.config)]
746
+ },
747
+ DROPPED: [{
748
+ guard: "mayReconnect",
749
+ target: ".dialing",
750
+ actions: ["forgetError", "retrying"]
751
+ }, {
752
+ target: "closed",
753
+ actions: "reportClose"
754
+ }]
755
+ },
756
+ states: {
757
+ dialing: { on: { OPENED: {
758
+ target: "awaitingHandshake",
759
+ actions: "opened"
760
+ } } },
761
+ awaitingHandshake: {
762
+ on: { OPENED: {
763
+ target: "awaitingHandshake",
764
+ reenter: true,
765
+ actions: "opened"
766
+ } },
767
+ after: { HANDSHAKE_TIMEOUT: [{
768
+ guard: "mayRedial",
769
+ target: "dialing",
770
+ actions: [
771
+ "spend",
772
+ ({ context }) => context.effects.redial(),
773
+ "retrying"
774
+ ]
775
+ }, {
776
+ target: "#connection.link.closed",
777
+ actions: ({ context }) => context.effects.exhausted()
778
+ }] }
779
+ },
780
+ live: { on: { OPENED: {
781
+ target: "awaitingHandshake",
782
+ actions: "opened"
783
+ } } }
784
+ }
785
+ }
786
+ }
787
+ },
788
+ retired: {
789
+ initial: "no",
790
+ states: {
791
+ no: { on: { RETIRED: "yes" } },
792
+ yes: { on: { DIAL: "no" } }
793
+ }
794
+ }
795
+ }
796
+ });
797
+ /** The socket half of {@link Link}: listeners in, teardown out. */
798
+ function createLink(effects, send) {
799
+ let current = null;
800
+ return {
801
+ dial(caller) {
802
+ const controller = new AbortController();
803
+ const { signal } = controller;
804
+ caller?.addEventListener("abort", () => effects.abandon(), { signal });
805
+ const socket = effects.dial();
806
+ current = {
807
+ socket,
808
+ controller
809
+ };
810
+ socket.addEventListener("open", () => send({ type: "OPENED" }), { signal });
811
+ socket.addEventListener("message", (event) => {
812
+ const config = effects.receive(event.data);
813
+ if (config) send({
814
+ type: "CONFIGURED",
815
+ config
816
+ });
817
+ }, { signal });
818
+ socket.addEventListener("error", () => send({ type: "ERRORED" }), { signal });
819
+ socket.addEventListener("close", (event) => send({
820
+ type: "DROPPED",
821
+ event,
822
+ reconnecting: effects.retryPending(socket)
823
+ }), { signal });
824
+ },
825
+ teardown() {
826
+ if (current === null) return;
827
+ const { socket, controller } = current;
828
+ current = null;
829
+ controller.abort();
830
+ effects.release(socket);
831
+ }
832
+ };
833
+ }
834
+ /** Create the connection machine for one browser session. */
835
+ function createConnection(effects) {
836
+ const link = createLink(effects, (event) => self.send(event));
837
+ const self = createActor(connectionMachine, { input: {
838
+ effects,
839
+ link
840
+ } }).start();
841
+ return {
842
+ dial: (signal) => {
843
+ self.send({
844
+ type: "DIAL",
845
+ signal
846
+ });
847
+ },
848
+ hangUp: () => {
849
+ self.send({ type: "HANG_UP" });
850
+ },
851
+ retire: () => {
852
+ self.send({ type: "RETIRED" });
853
+ },
854
+ phase: () => {
855
+ const at = self.getSnapshot().value.link;
856
+ return typeof at === "string" ? "closed" : at.open;
857
+ }
858
+ };
859
+ }
860
+ //#endregion
649
861
  //#region src/session/reconnect.ts
650
862
  /**
651
863
  * Automatic reconnection for the browser session socket, built on
@@ -676,6 +888,13 @@ function openReconnectingSocket(urlProvider, protocolsProvider) {
676
888
  return new ReconnectingWebSocket(urlProvider, protocolsProvider, RECONNECT_OPTIONS);
677
889
  }
678
890
  /**
891
+ * Does `socket` have reconnect machinery? False for an injected
892
+ * `options.WebSocket` (tests), which a failure then ends for good.
893
+ */
894
+ function canReconnect(socket) {
895
+ return socket instanceof ReconnectingWebSocket;
896
+ }
897
+ /**
679
898
  * Force a fresh connection attempt on a reconnecting socket, reporting
680
899
  * whether it was one. Used for a failure partysocket cannot see: a socket it
681
900
  * considers open and healthy, whose peer never completed OUR handshake.
@@ -1018,67 +1237,6 @@ function createDialer(options, onSessionId) {
1018
1237
  };
1019
1238
  }
1020
1239
  //#endregion
1021
- //#region src/session/handshake.ts
1022
- /** What the session reports once the budget below is spent. */
1023
- const HANDSHAKE_ERROR = {
1024
- code: "connection",
1025
- message: "Agent did not complete the session handshake",
1026
- fatal: false
1027
- };
1028
- /** How long an OPEN socket may go without a `config` frame. */
1029
- const HANDSHAKE_TIMEOUT_MS = 1e4;
1030
- /**
1031
- * How many consecutive handshake timeouts to ride out before giving up.
1032
- *
1033
- * `forceReconnect` restarts partysocket's own retry budget, so this is the
1034
- * budget for this failure mode — without one, a permanently wedged peer would
1035
- * be re-dialed every ~10s forever, which is the unbounded retry loop
1036
- * `RECONNECT_OPTIONS.maxRetries` exists to prevent.
1037
- *
1038
- * CONSECUTIVE is the whole of it, and only `succeeded()` says so — see its doc.
1039
- */
1040
- const MAX_HANDSHAKE_TIMEOUTS = 3;
1041
- /**
1042
- * Watch one connection's handshake.
1043
- *
1044
- * `onRetry` fires when a timed-out attempt has been re-dialed (the sandbox
1045
- * behind the endpoint may have been replaced, and the URL provider re-brokers
1046
- * on the next attempt); `onExhausted` when the budget is spent, or when the
1047
- * socket has no reconnect machinery at all (an injected `options.WebSocket`).
1048
- *
1049
- * @internal
1050
- */
1051
- function createHandshakeGuard(opts) {
1052
- let timer;
1053
- let timeouts = 0;
1054
- function disarm() {
1055
- if (timer === void 0) return;
1056
- clearTimeout(timer);
1057
- timer = void 0;
1058
- }
1059
- function fire() {
1060
- timer = void 0;
1061
- timeouts += 1;
1062
- if (timeouts < MAX_HANDSHAKE_TIMEOUTS && forceReconnect(opts.socket)) {
1063
- opts.onRetry();
1064
- return;
1065
- }
1066
- opts.onExhausted();
1067
- }
1068
- opts.signal.addEventListener("abort", disarm);
1069
- return {
1070
- arm() {
1071
- disarm();
1072
- timer = setTimeout(fire, HANDSHAKE_TIMEOUT_MS);
1073
- },
1074
- disarm,
1075
- succeeded() {
1076
- disarm();
1077
- timeouts = 0;
1078
- }
1079
- };
1080
- }
1081
- //#endregion
1082
1240
  //#region src/session/messages.ts
1083
1241
  /**
1084
1242
  * Incoming-message handling for the voice session core.
@@ -1124,7 +1282,7 @@ function appendCapped(list, item, cap) {
1124
1282
  * `ConnState.turn`).
1125
1283
  */
1126
1284
  function createMessageHandlers(deps) {
1127
- const { getSnapshot, updateState, conn, agentState, audio } = deps;
1285
+ const { getSnapshot, updateState, conn, agentState, audio, retire } = deps;
1128
1286
  /** Monotonically increasing counter for custom events -- used by useEvent to deduplicate. */
1129
1287
  let customEventSeq = 0;
1130
1288
  /** Monotonically increasing counter for chat messages -- stable render keys
@@ -1332,7 +1490,7 @@ function createMessageHandlers(deps) {
1332
1490
  case "error.reported":
1333
1491
  handleErrorEvent(e);
1334
1492
  break;
1335
- case "session.timedOut": conn.retiredByServer = true;
1493
+ case "session.timedOut": retire();
1336
1494
  }
1337
1495
  }
1338
1496
  /** Enqueue a PCM16 audio chunk for playback. Transitions state to `"speaking"` on the first chunk. */
@@ -1895,10 +2053,8 @@ function createBrowserSessionWith(options, internals) {
1895
2053
  const agentState = createSessionStateMachine();
1896
2054
  const conn = {
1897
2055
  ws: null,
1898
- retiredByServer: false,
1899
2056
  turn: createEpoch()
1900
2057
  };
1901
- let connectionController = null;
1902
2058
  const identity = createSessionIdentity(options, () => dialer);
1903
2059
  const dialer = createDialer({
1904
2060
  ...options,
@@ -1941,17 +2097,9 @@ function createBrowserSessionWith(options, internals) {
1941
2097
  updateState,
1942
2098
  conn,
1943
2099
  agentState,
1944
- audio
2100
+ audio,
2101
+ retire: () => connection.retire()
1945
2102
  });
1946
- /** Abort the in-flight connection and release audio + WebSocket resources. */
1947
- function teardownConnection() {
1948
- connectionController?.abort();
1949
- connectionController = null;
1950
- preConnect.release();
1951
- audio.teardown();
1952
- conn.ws?.close();
1953
- conn.ws = null;
1954
- }
1955
2103
  /**
1956
2104
  * React to the server's `session.configured` frame: record it and set up the
1957
2105
  * session's audio path. **It no longer replays history, and that is the
@@ -1966,78 +2114,43 @@ function createBrowserSessionWith(options, internals) {
1966
2114
  flushQueued();
1967
2115
  audio.start(config);
1968
2116
  }
1969
- function connect(opts) {
1970
- if (opts?.signal?.aborted) {
1971
- disconnect();
1972
- return;
1973
- }
1974
- updateState(agentState.apply({ type: "CONNECT" }));
1975
- teardownConnection();
1976
- preConnect.begin();
1977
- conn.retiredByServer = false;
1978
- const controller = new AbortController();
1979
- connectionController = controller;
1980
- const { signal: sig } = controller;
1981
- if (opts?.signal) opts.signal.addEventListener("abort", () => disconnect(), { signal: sig });
1982
- const socket = dialer.open();
1983
- socket.binaryType = "arraybuffer";
1984
- conn.ws = socket;
1985
- let socketErrored = false;
1986
- const handshake = createHandshakeGuard({
1987
- socket,
1988
- signal: sig,
1989
- onRetry: () => {
1990
- audio.teardown();
1991
- updateState({
1992
- ...agentState.apply({ type: "CONNECT" }),
1993
- recording: false
1994
- });
1995
- },
1996
- onExhausted: () => {
1997
- preConnect.release();
1998
- audio.teardown();
1999
- controller.abort();
2000
- socket.close();
2001
- conn.ws = null;
2002
- updateState({
2003
- ...agentState.apply({
2004
- type: "FAILED",
2005
- error: HANDSHAKE_ERROR
2006
- }),
2007
- ...STOPPED
2008
- });
2009
- }
2010
- });
2011
- socket.addEventListener("open", () => {
2012
- updateState(agentState.apply({ type: "SOCKET_OPEN" }));
2013
- handshake.arm();
2014
- }, { signal: sig });
2015
- socket.addEventListener("message", (event) => {
2016
- const config = handleMessage(event.data);
2017
- if (!config) return;
2018
- handshake.succeeded();
2019
- onServerConfig(config);
2020
- }, { signal: sig });
2021
- socket.addEventListener("error", () => {
2022
- socketErrored = true;
2023
- }, { signal: sig });
2024
- socket.addEventListener("close", (event) => {
2025
- if (sig.aborted) return;
2026
- handshake.disarm();
2027
- audio.teardown();
2028
- if (!(conn.retiredByServer || agentState.fatal()) && reconnectPending(socket)) {
2029
- socketErrored = false;
2030
- updateState({
2031
- ...agentState.apply({ type: "CONNECT" }),
2032
- recording: false
2033
- });
2034
- return;
2035
- }
2117
+ /**
2118
+ * The socket, the handshake deadline over it and the server's idle
2119
+ * retirement, as a statechart — see `session/connection.ts`. `release` is the
2120
+ * exit action of its `open` state, so every way a connection ends shares it.
2121
+ */
2122
+ const connection = createConnection({
2123
+ dial: () => {
2124
+ preConnect.begin();
2125
+ const socket = dialer.open();
2126
+ socket.binaryType = "arraybuffer";
2127
+ conn.ws = socket;
2128
+ return socket;
2129
+ },
2130
+ release: (socket) => {
2036
2131
  preConnect.release();
2037
- controller.abort();
2132
+ audio.teardown();
2038
2133
  socket.close();
2039
2134
  conn.ws = null;
2040
- const failure = closeFailure(event, socketErrored);
2135
+ },
2136
+ abandon: () => disconnect(),
2137
+ receive: handleMessage,
2138
+ opened: () => updateState(agentState.apply({ type: "SOCKET_OPEN" })),
2139
+ configured: onServerConfig,
2140
+ fatal: () => agentState.fatal(),
2141
+ retryPending: reconnectPending,
2142
+ canRedial: () => canReconnect(conn.ws),
2143
+ redial: () => {
2144
+ forceReconnect(conn.ws);
2145
+ },
2146
+ retrying: () => {
2147
+ audio.teardown();
2148
+ updateState({
2149
+ ...agentState.apply({ type: "CONNECT" }),
2150
+ recording: false
2151
+ });
2152
+ },
2153
+ closed: (failure) => {
2041
2154
  updateState({
2042
2155
  ...failure === null ? agentState.apply({ type: "CLOSED" }) : agentState.apply({
2043
2156
  type: "FAILED",
@@ -2049,7 +2162,24 @@ function createBrowserSessionWith(options, internals) {
2049
2162
  }),
2050
2163
  ...STOPPED
2051
2164
  });
2052
- }, { signal: sig });
2165
+ },
2166
+ exhausted: () => {
2167
+ updateState({
2168
+ ...agentState.apply({
2169
+ type: "FAILED",
2170
+ error: HANDSHAKE_ERROR
2171
+ }),
2172
+ ...STOPPED
2173
+ });
2174
+ }
2175
+ });
2176
+ function connect(opts) {
2177
+ if (opts?.signal?.aborted) {
2178
+ disconnect();
2179
+ return;
2180
+ }
2181
+ updateState(agentState.apply({ type: "CONNECT" }));
2182
+ connection.dial(opts?.signal);
2053
2183
  }
2054
2184
  function cancel() {
2055
2185
  if (!openSocket()) return;
@@ -2079,7 +2209,7 @@ function createBrowserSessionWith(options, internals) {
2079
2209
  start();
2080
2210
  }
2081
2211
  function disconnect() {
2082
- teardownConnection();
2212
+ connection.hangUp();
2083
2213
  updateState({
2084
2214
  ...agentState.apply({ type: "DISCONNECT" }),
2085
2215
  ...STOPPED
@@ -2100,7 +2230,7 @@ function createBrowserSessionWith(options, internals) {
2100
2230
  }
2101
2231
  }
2102
2232
  function end() {
2103
- teardownConnection();
2233
+ connection.hangUp();
2104
2234
  dialer.forget();
2105
2235
  updateState({
2106
2236
  ...CLEARED_SESSION_STATE,
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The workflow hooks' upload machinery, as the rest of the package sees it:
3
3
  * claiming an id per file and transferring it (`files.ts`), the pause gate
4
- * (`session.ts`, `pause.ts`), the reload recall (`recall.ts`) and the report
4
+ * (`session.ts` — the buttons over it are the form's statechart,
5
+ * `../_workflow-form-state.ts`), the reload recall (`recall.ts`) and the report
5
6
  * coalescing (`report.ts`). Everything not re-exported here is private to
6
7
  * `upload/` — guard-invariants rule 37 fails an import from outside it that
7
8
  * names any module but this one.
@@ -9,7 +10,6 @@
9
10
  * @module
10
11
  */
11
12
  export { createUploadSession, type UploadSession, uploadFiles } from "./files.ts";
12
- export { useUploadPause } from "./pause.ts";
13
13
  export { recallUploadId, rememberUploadId } from "./recall.ts";
14
14
  export { coalesceUploadReports } from "./report.ts";
15
15
  export { createUploadGate, randomUploadId, sendThroughGate, type UploadGate, } from "./session.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,7 +46,7 @@
46
46
  "use-stick-to-bottom": "^1.1.6",
47
47
  "use-sync-external-store": "^1.7.0",
48
48
  "xstate": "^5.33.2",
49
- "@alexkroman1/aai": "0.14.1"
49
+ "@alexkroman1/aai": "0.14.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.3",
@@ -1,31 +0,0 @@
1
- /**
2
- * The two buttons a page puts over a live upload, bound to whichever gate the
3
- * submission is currently holding.
4
- *
5
- * `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
6
- * both reported the pause the same way: park the gate, then fold `paused` into
7
- * the status the bar is already drawing. Folded rather than replaced, because
8
- * everything else about that status — which file, how far, of how many — is
9
- * still true. Two copies of that rule are two copies that can stop agreeing
10
- * about what a paused bar says.
11
- */
12
- import { type Dispatch, type SetStateAction } from "react";
13
- import type { UploadStatus } from "../use-workflow-form.ts";
14
- import type { UploadGate } from "./session.ts";
15
- /** What {@link useUploadPause} returns — see {@link WorkflowSubmission}. */
16
- export type UploadPause = {
17
- /** Stop the bytes in flight and hold the uploader. */
18
- pauseUpload: () => void;
19
- /** Send whatever the store is still missing. */
20
- resumeUpload: () => void;
21
- };
22
- /**
23
- * Bind pause/resume to the live submission's gate.
24
- *
25
- * @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
26
- * stable across submissions rather than being re-created per gate.
27
- * @param setUpload - The status setter the progress bar renders from.
28
- *
29
- * @internal
30
- */
31
- export declare function useUploadPause(getGate: () => UploadGate | undefined, setUpload: Dispatch<SetStateAction<UploadStatus | undefined>>): UploadPause;