reactor-effect-client 0.2.0 → 0.3.0-rc.0

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 (218) hide show
  1. package/README.md +62 -6
  2. package/dist/FetchHttp.js +1 -1
  3. package/dist/FetchHttp.js.map +1 -1
  4. package/dist/PeerFactory.d.ts +13 -2
  5. package/dist/PeerFactory.d.ts.map +1 -1
  6. package/dist/PeerFactory.js.map +1 -1
  7. package/dist/PeerTypes.d.ts +2 -2
  8. package/dist/PeerTypes.d.ts.map +1 -1
  9. package/dist/Sequence.d.ts +18 -14
  10. package/dist/Sequence.d.ts.map +1 -1
  11. package/dist/Sequence.js +117 -122
  12. package/dist/Sequence.js.map +1 -1
  13. package/dist/SessionTypes.d.ts +168 -15
  14. package/dist/SessionTypes.d.ts.map +1 -1
  15. package/dist/SessionTypes.js +18 -1
  16. package/dist/SessionTypes.js.map +1 -1
  17. package/dist/bytes.js +3 -3
  18. package/dist/bytes.js.map +1 -1
  19. package/dist/contract.d.ts +120 -29
  20. package/dist/contract.d.ts.map +1 -1
  21. package/dist/contract.js +105 -73
  22. package/dist/contract.js.map +1 -1
  23. package/dist/coordinator/_internal/client.d.ts +111 -17
  24. package/dist/coordinator/_internal/client.d.ts.map +1 -1
  25. package/dist/coordinator/_internal/client.js +187 -74
  26. package/dist/coordinator/_internal/client.js.map +1 -1
  27. package/dist/coordinator/_internal/recording.d.ts +33 -0
  28. package/dist/coordinator/_internal/recording.d.ts.map +1 -0
  29. package/dist/coordinator/_internal/recording.js +123 -0
  30. package/dist/coordinator/_internal/recording.js.map +1 -0
  31. package/dist/coordinator/_internal/response.d.ts.map +1 -1
  32. package/dist/coordinator/_internal/response.js +5 -7
  33. package/dist/coordinator/_internal/response.js.map +1 -1
  34. package/dist/coordinator/_internal/schemas.d.ts +40 -6
  35. package/dist/coordinator/_internal/schemas.d.ts.map +1 -1
  36. package/dist/coordinator/_internal/schemas.js +94 -64
  37. package/dist/coordinator/_internal/schemas.js.map +1 -1
  38. package/dist/coordinator/index.d.ts +14 -2
  39. package/dist/coordinator/index.d.ts.map +1 -1
  40. package/dist/coordinator/index.js +14 -18
  41. package/dist/coordinator/index.js.map +1 -1
  42. package/dist/correlation.d.ts +15 -0
  43. package/dist/correlation.d.ts.map +1 -1
  44. package/dist/correlation.js +26 -8
  45. package/dist/correlation.js.map +1 -1
  46. package/dist/duration.d.ts +25 -0
  47. package/dist/duration.d.ts.map +1 -0
  48. package/dist/duration.js +47 -0
  49. package/dist/duration.js.map +1 -0
  50. package/dist/errors.d.ts +1264 -49
  51. package/dist/errors.d.ts.map +1 -1
  52. package/dist/errors.js +698 -46
  53. package/dist/errors.js.map +1 -1
  54. package/dist/h3/_internal/client.d.ts +2 -2
  55. package/dist/h3/_internal/client.d.ts.map +1 -1
  56. package/dist/h3/_internal/client.js +210 -125
  57. package/dist/h3/_internal/client.js.map +1 -1
  58. package/dist/h3/_internal/contracts.d.ts.map +1 -1
  59. package/dist/h3/_internal/contracts.js +2 -2
  60. package/dist/h3/_internal/contracts.js.map +1 -1
  61. package/dist/h3/_internal/deployment.d.ts.map +1 -1
  62. package/dist/h3/_internal/deployment.js +5 -4
  63. package/dist/h3/_internal/deployment.js.map +1 -1
  64. package/dist/h3/_internal/evidence.js +1 -1
  65. package/dist/h3/_internal/evidence.js.map +1 -1
  66. package/dist/h3/_internal/operations.d.ts +48 -0
  67. package/dist/h3/_internal/operations.d.ts.map +1 -0
  68. package/dist/h3/_internal/operations.js +230 -0
  69. package/dist/h3/_internal/operations.js.map +1 -0
  70. package/dist/h3/_internal/references.d.ts.map +1 -1
  71. package/dist/h3/_internal/references.js +3 -8
  72. package/dist/h3/_internal/references.js.map +1 -1
  73. package/dist/h3/_internal/request.js +1 -1
  74. package/dist/h3/_internal/request.js.map +1 -1
  75. package/dist/h3/_internal/retained.d.ts +22 -0
  76. package/dist/h3/_internal/retained.d.ts.map +1 -0
  77. package/dist/h3/_internal/retained.js +89 -0
  78. package/dist/h3/_internal/retained.js.map +1 -0
  79. package/dist/h3/_internal/state.d.ts +15 -1
  80. package/dist/h3/_internal/state.d.ts.map +1 -1
  81. package/dist/h3/_internal/state.js +69 -29
  82. package/dist/h3/_internal/state.js.map +1 -1
  83. package/dist/h3/index.d.ts +1 -1
  84. package/dist/h3/index.d.ts.map +1 -1
  85. package/dist/h3/messages.d.ts +118 -109
  86. package/dist/h3/messages.d.ts.map +1 -1
  87. package/dist/h3/messages.js +73 -51
  88. package/dist/h3/messages.js.map +1 -1
  89. package/dist/h3/types.d.ts +108 -13
  90. package/dist/h3/types.d.ts.map +1 -1
  91. package/dist/host.d.ts +6 -6
  92. package/dist/host.d.ts.map +1 -1
  93. package/dist/host.js +4 -5
  94. package/dist/host.js.map +1 -1
  95. package/dist/index.d.ts +11 -5
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +5 -1
  98. package/dist/index.js.map +1 -1
  99. package/dist/json.d.ts +13 -6
  100. package/dist/json.d.ts.map +1 -1
  101. package/dist/json.js +34 -14
  102. package/dist/json.js.map +1 -1
  103. package/dist/media-stream.d.ts.map +1 -1
  104. package/dist/media-stream.js +9 -11
  105. package/dist/media-stream.js.map +1 -1
  106. package/dist/observation.d.ts +9 -0
  107. package/dist/observation.d.ts.map +1 -1
  108. package/dist/observation.js +20 -18
  109. package/dist/observation.js.map +1 -1
  110. package/dist/orchestration/h3-source.d.ts +32 -4
  111. package/dist/orchestration/h3-source.d.ts.map +1 -1
  112. package/dist/orchestration/h3-source.js +47 -14
  113. package/dist/orchestration/h3-source.js.map +1 -1
  114. package/dist/orchestration/index.d.ts +9 -6
  115. package/dist/orchestration/index.d.ts.map +1 -1
  116. package/dist/orchestration/index.js +5 -3
  117. package/dist/orchestration/index.js.map +1 -1
  118. package/dist/orchestration/media-buffer.d.ts +12 -4
  119. package/dist/orchestration/media-buffer.d.ts.map +1 -1
  120. package/dist/orchestration/media-buffer.js +31 -11
  121. package/dist/orchestration/media-buffer.js.map +1 -1
  122. package/dist/orchestration/open-h3.d.ts +65 -0
  123. package/dist/orchestration/open-h3.d.ts.map +1 -0
  124. package/dist/orchestration/open-h3.js +65 -0
  125. package/dist/orchestration/open-h3.js.map +1 -0
  126. package/dist/orchestration/references.d.ts +5 -1
  127. package/dist/orchestration/references.d.ts.map +1 -1
  128. package/dist/orchestration/references.js +37 -34
  129. package/dist/orchestration/references.js.map +1 -1
  130. package/dist/orchestration/renewal-state.js +1 -1
  131. package/dist/orchestration/renewal-state.js.map +1 -1
  132. package/dist/orchestration/renewal.d.ts +64 -64
  133. package/dist/orchestration/renewal.d.ts.map +1 -1
  134. package/dist/orchestration/renewal.js +149 -88
  135. package/dist/orchestration/renewal.js.map +1 -1
  136. package/dist/orchestration/request.d.ts +11 -12
  137. package/dist/orchestration/request.d.ts.map +1 -1
  138. package/dist/orchestration/request.js +63 -66
  139. package/dist/orchestration/request.js.map +1 -1
  140. package/dist/orchestration/routing.d.ts.map +1 -1
  141. package/dist/orchestration/routing.js +17 -14
  142. package/dist/orchestration/routing.js.map +1 -1
  143. package/dist/orchestration/source-slot.d.ts +15 -5
  144. package/dist/orchestration/source-slot.d.ts.map +1 -1
  145. package/dist/orchestration/source-slot.js +40 -21
  146. package/dist/orchestration/source-slot.js.map +1 -1
  147. package/dist/orchestration/types.d.ts +594 -39
  148. package/dist/orchestration/types.d.ts.map +1 -1
  149. package/dist/orchestration/types.js +20 -0
  150. package/dist/orchestration/types.js.map +1 -1
  151. package/dist/protobuf.js +2 -2
  152. package/dist/protobuf.js.map +1 -1
  153. package/dist/session/_internal/acquire.d.ts +4 -6
  154. package/dist/session/_internal/acquire.d.ts.map +1 -1
  155. package/dist/session/_internal/acquire.js +40 -50
  156. package/dist/session/_internal/acquire.js.map +1 -1
  157. package/dist/session/_internal/cleanup.d.ts +2 -1
  158. package/dist/session/_internal/cleanup.d.ts.map +1 -1
  159. package/dist/session/_internal/cleanup.js +23 -15
  160. package/dist/session/_internal/cleanup.js.map +1 -1
  161. package/dist/session/_internal/lifecycle.d.ts +5 -0
  162. package/dist/session/_internal/lifecycle.d.ts.map +1 -1
  163. package/dist/session/_internal/lifecycle.js +16 -6
  164. package/dist/session/_internal/lifecycle.js.map +1 -1
  165. package/dist/session/_internal/remote.d.ts.map +1 -1
  166. package/dist/session/_internal/remote.js +16 -7
  167. package/dist/session/_internal/remote.js.map +1 -1
  168. package/dist/session/_internal/uploads.d.ts +4 -1
  169. package/dist/session/_internal/uploads.d.ts.map +1 -1
  170. package/dist/session/_internal/uploads.js +25 -27
  171. package/dist/session/_internal/uploads.js.map +1 -1
  172. package/dist/session/commands.d.ts +1 -21
  173. package/dist/session/commands.d.ts.map +1 -1
  174. package/dist/session/commands.js +1 -12
  175. package/dist/session/commands.js.map +1 -1
  176. package/dist/session/files.d.ts +10 -2
  177. package/dist/session/files.d.ts.map +1 -1
  178. package/dist/session/files.js +11 -16
  179. package/dist/session/files.js.map +1 -1
  180. package/dist/session/index.d.ts +6 -6
  181. package/dist/session/index.d.ts.map +1 -1
  182. package/dist/session/index.js.map +1 -1
  183. package/dist/session/media.d.ts +53 -6
  184. package/dist/session/media.d.ts.map +1 -1
  185. package/dist/session/recorder.d.ts +24 -0
  186. package/dist/session/recorder.d.ts.map +1 -0
  187. package/dist/session/recorder.js +19 -0
  188. package/dist/session/recorder.js.map +1 -0
  189. package/dist/session.d.ts +5 -4
  190. package/dist/session.d.ts.map +1 -1
  191. package/dist/session.js +208 -109
  192. package/dist/session.js.map +1 -1
  193. package/dist/simulation/_internal/source.d.ts.map +1 -1
  194. package/dist/simulation/_internal/source.js +57 -48
  195. package/dist/simulation/_internal/source.js.map +1 -1
  196. package/dist/simulation/index.d.ts +4 -5
  197. package/dist/simulation/index.d.ts.map +1 -1
  198. package/dist/simulation/index.js +3 -4
  199. package/dist/simulation/index.js.map +1 -1
  200. package/dist/simulation/types.d.ts +16 -2
  201. package/dist/simulation/types.d.ts.map +1 -1
  202. package/dist/stats.d.ts.map +1 -1
  203. package/dist/stats.js +4 -5
  204. package/dist/stats.js.map +1 -1
  205. package/package.json +5 -4
  206. package/notices/owned-webrtc/Effect-MIT.txt +0 -21
  207. package/notices/owned-webrtc/IETF.txt +0 -43
  208. package/notices/owned-webrtc/LICENSE +0 -21
  209. package/notices/owned-webrtc/UPSTREAM.md +0 -20
  210. package/notices/owned-webrtc/VP8-ADAPTATION.md +0 -32
  211. package/notices/owned-webrtc/VP8-AUTHORS.txt +0 -42
  212. package/notices/owned-webrtc/VP8-LICENSE.txt +0 -25
  213. package/notices/owned-webrtc/VP8-PATENTS.txt +0 -21
  214. package/notices/owned-webrtc/Werift-MIT.txt +0 -21
  215. package/notices/owned-webrtc/libvpx-v1.12.0-LICENSE.txt +0 -28
  216. package/notices/owned-webrtc/libvpx-v1.12.0-PATENTS.txt +0 -22
  217. package/notices/owned-webrtc/rfc3711.txt +0 -3139
  218. package/notices/owned-webrtc/str0m-MIT.txt +0 -7
@@ -6,87 +6,82 @@ import * as Result from "effect/Result";
6
6
  import * as Scope from "effect/Scope";
7
7
  import * as Semaphore from "effect/Semaphore";
8
8
  import * as Stream from "effect/Stream";
9
- import { errorOf, positiveLimit, ReactorError } from "../../errors.js";
9
+ import { CommandFailure, parsed, PolicyFailure, positiveLimit, ReactorError, Remote, } from "../../errors.js";
10
+ import { duration } from "../../duration.js";
10
11
  import { Observations } from "../../observation.js";
11
- import { CommandFailure } from "../../session/commands.js";
12
12
  import * as Submission from "../../Submission.js";
13
13
  import { decodeMessage } from "../messages.js";
14
+ import { Operations } from "./operations.js";
14
15
  import { canvases } from "../profile.js";
15
16
  import { Commands } from "./contracts.js";
16
17
  import { validateDeployment } from "./deployment.js";
17
18
  import { acceptanceFor, encodeMetadata, submissionFromMetadata } from "./evidence.js";
18
19
  import { checkedUpload, referenceMaterial } from "./references.js";
19
20
  import { captureRequest, clipId, enqueueArguments, nonnegative, seconds } from "./request.js";
21
+ import { sizeOf } from "./retained.js";
20
22
  import { ProviderState } from "./state.js";
21
- const pure = (evaluate) => Effect.try({ try: evaluate, catch: errorOf });
22
- const localFailure = (operation, cause) => new CommandFailure(cause, { ...cause.context, operation, outcome: "not-submitted" });
23
- const uncertain = (operation, source, message, cause) => new CommandFailure(cause ?? new ReactorError("UnexpectedReply", message), {
23
+ const pure = (evaluate) => parsed(evaluate);
24
+ const localFailure = (operation, cause) => CommandFailure.from(cause, { ...cause.context, operation, outcome: "not-submitted" });
25
+ /** A caller's local refusal already proves no dispatch; anything else becomes one. */
26
+ const preparationFailure = (operation, cause) => ReactorError.is(cause) || CommandFailure.is(cause) ? localFailure(operation, cause) : cause;
27
+ const uncertain = (operation, source, message, cause) => CommandFailure.from(cause ?? ReactorError.fromCode("UnexpectedReply", message), {
24
28
  ...(cause?.context ?? {}),
25
29
  operation,
26
30
  outcome: "unknown",
27
31
  requestId: source.requestId,
28
32
  generation: source.generation,
29
33
  });
30
- const rejected = (operation, source) => new CommandFailure(new ReactorError("Remote", `H3 ${operation} was refused`), {
31
- operation,
32
- outcome: "replied",
33
- requestId: source.requestId,
34
- generation: source.generation,
34
+ /** The refusal reason is provider free text with no stable codes: kept for diagnosis only. */
35
+ const rejected = (operation, source, reason) => new CommandFailure({
36
+ reason: new Remote({ _tag: "Remote", message: `H3 ${operation} was refused`, body: reason }),
37
+ context: {
38
+ operation,
39
+ outcome: "replied",
40
+ requestId: source.requestId,
41
+ generation: source.generation,
42
+ },
35
43
  });
36
44
  const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
37
- /** Conservative retained-size accounting includes shared objects only once. */
38
- const sizeOf = (input) => {
39
- const pending = [input], seen = new Set();
40
- let bytes = 0, nodes = 0;
41
- while (pending.length > 0) {
42
- if (++nodes > 1_000_000)
43
- return Number.MAX_SAFE_INTEGER;
44
- const value = pending.pop();
45
- if (typeof value === "string")
46
- bytes += value.length * 3;
47
- else if (value !== null && typeof value === "object") {
48
- if (seen.has(value))
49
- continue;
50
- seen.add(value);
51
- if (ArrayBuffer.isView(value))
52
- bytes += value.byteLength;
53
- else if (value instanceof ArrayBuffer)
54
- bytes += value.byteLength;
55
- else if (value instanceof Map) {
56
- bytes += value.size * 32;
57
- for (const [key, child] of value) {
58
- pending.push(key);
59
- pending.push(child);
60
- }
61
- }
62
- else if (value instanceof Set) {
63
- bytes += value.size * 16;
64
- for (const child of value)
65
- pending.push(child);
66
- }
67
- else {
68
- bytes += 32;
69
- for (const child of Object.values(value))
70
- pending.push(child);
71
- }
72
- }
73
- else
74
- bytes += 8;
45
+ /**
46
+ * Whether a snapshot at `revision` already reflects `event`: the reducer
47
+ * handles session events in sequence order. An acceptance is local evidence
48
+ * that no snapshot holds, and a diagnostic without a source has no sequence,
49
+ * so neither is ever covered.
50
+ */
51
+ const covered = (event, revision) => {
52
+ switch (event._tag) {
53
+ case "Acceptance":
54
+ return false;
55
+ case "Diagnostic":
56
+ return event.source !== undefined && event.source.sequence <= revision;
57
+ default:
58
+ return event.source.sequence <= revision;
75
59
  }
76
- return bytes;
77
60
  };
78
61
  const build = (session, options) => Effect.gen(function* () {
79
62
  const scope = yield* Effect.scope;
80
63
  const crypto = yield* Crypto.Crypto;
81
64
  const limits = yield* pure(() => ({
82
- command: positiveLimit(options.commandTimeoutMs ?? 15000, "H3 command deadline", 600000),
83
- setup: positiveLimit(options.setupTimeoutMs ?? 60000, "H3 setup deadline", 600000),
84
- reconcile: positiveLimit(options.reconcileWindowMs ?? 5000, "H3 reconciliation deadline", 60000),
85
- hook: positiveLimit(options.resultHookTimeoutMs ?? 1000, "H3 result hook deadline", 60000),
65
+ command: duration(options.replyTimeout ?? "15 seconds", "H3 reply timeout", {
66
+ maximum: "10 minutes",
67
+ }),
68
+ upload: duration(options.uploadTimeout ?? "60 seconds", "H3 upload timeout", {
69
+ maximum: "10 minutes",
70
+ }),
71
+ setup: duration(options.setupTimeout ?? "60 seconds", "H3 setup timeout", {
72
+ maximum: "10 minutes",
73
+ }),
74
+ reconcile: duration(options.reconcileWindow ?? "5 seconds", "H3 reconciliation window", {
75
+ maximum: "1 minute",
76
+ }),
77
+ hook: duration(options.resultHookTimeout ?? "1 second", "H3 result hook timeout", {
78
+ maximum: "1 minute",
79
+ }),
86
80
  pending: positiveLimit(options.maxPending ?? 128, "H3 pending acceptance bound", 4096),
87
81
  clips: positiveLimit(options.maxTrackedClips ?? 4096, "H3 clip observation bound", 16384),
88
82
  acceptances: positiveLimit(options.maxAcceptances ?? 1024, "H3 local acceptance bound", 16384),
89
83
  cache: positiveLimit(options.maxCachedUploads ?? 256, "H3 upload cache bound", 4096),
84
+ operations: positiveLimit(options.maxOperations ?? 1024, "H3 clip operation bound", 16384),
90
85
  prompt: positiveLimit(options.maxPromptBytes ?? 1048576, "H3 prompt byte bound", 4194304),
91
86
  retained: positiveLimit(options.maxRetainedBytes ?? 16777216, "H3 retained byte bound", 67108864),
92
87
  }));
@@ -95,7 +90,7 @@ const build = (session, options) => Effect.gen(function* () {
95
90
  yield* session.ready;
96
91
  const observation = yield* session.observe(options.observation);
97
92
  const state = new ProviderState(session.id, observation.initial.generation, observation.revision, limits.clips);
98
- const namespace = hex(yield* crypto.randomBytes(16).pipe(Effect.mapError(() => new ReactorError("InvalidState", "Could not allocate H3 acceptance namespace", {
93
+ const namespace = hex(yield* crypto.randomBytes(16).pipe(Effect.mapError(() => ReactorError.fromCode("InvalidState", "Could not allocate H3 acceptance namespace", {
99
94
  outcome: "not-submitted",
100
95
  }))));
101
96
  let counter = 0n, closed = false, fatalError;
@@ -103,10 +98,33 @@ const build = (session, options) => Effect.gen(function* () {
103
98
  const events = new Observations();
104
99
  const pending = new Map();
105
100
  const acceptances = new Map();
101
+ /** Acceptances count toward the reducer's retained bytes while the map holds them. */
102
+ const retain = (acceptance, direction) => {
103
+ state.retained.add(direction * (64 + acceptance.submissionId.length * 3));
104
+ if (direction === 1) {
105
+ state.retained.hold(acceptance.clip);
106
+ state.retained.hold(acceptance.evidence.source);
107
+ }
108
+ else {
109
+ state.retained.drop(acceptance.clip);
110
+ state.retained.drop(acceptance.evidence.source);
111
+ }
112
+ };
113
+ const operations = new Operations(limits.operations);
106
114
  const decoded = new WeakMap();
107
115
  const observedWaiters = new Map();
108
116
  const uploads = new Map();
109
117
  const uploadGate = yield* Semaphore.make(1);
118
+ // Callers waiting for the provider to synchronize; every applied event and
119
+ // any failure wakes them to look again. A woken caller may wait again at
120
+ // once, so the set is emptied before any waiter is released.
121
+ const synchronizing = new Set();
122
+ const wake = () => {
123
+ const waiters = [...synchronizing];
124
+ synchronizing.clear();
125
+ for (const waiter of waiters)
126
+ Deferred.doneUnsafe(waiter, Effect.void);
127
+ };
110
128
  const emit = (event) => events.emit(event, sizeOf(event));
111
129
  const fail = (error) => {
112
130
  if (fatalError !== undefined)
@@ -119,10 +137,14 @@ const build = (session, options) => Effect.gen(function* () {
119
137
  for (const waiter of observedWaiters.values())
120
138
  Deferred.doneUnsafe(waiter, Effect.fail(error));
121
139
  emit({ _tag: "Diagnostic", error });
140
+ wake();
122
141
  };
123
142
  yield* Effect.addFinalizer(() => Effect.sync(() => {
124
143
  closed = true;
125
- fail(new ReactorError("Closed", "H3 provider scope closed", { operation: "H3 observation" }));
144
+ fail(ReactorError.fromCode("Closed", "H3 provider scope closed", {
145
+ operation: "H3 observation",
146
+ }));
147
+ operations.retire();
126
148
  events.end();
127
149
  pending.clear();
128
150
  observedWaiters.clear();
@@ -133,25 +155,31 @@ const build = (session, options) => Effect.gen(function* () {
133
155
  if (id === undefined)
134
156
  return;
135
157
  const entry = pending.get(id);
136
- if (entry === undefined)
137
- return;
138
- const acceptance = acceptanceFor(entry, clip, source);
139
- if (acceptance === undefined)
158
+ const acceptance = entry === undefined ? undefined : acceptanceFor(entry, clip, source);
159
+ if (entry === undefined || acceptance === undefined) {
160
+ // After the reconcile window, or in a later transport generation, the
161
+ // evidence can still resolve the operation, never the acceptances.
162
+ operations.lateEvidence(id, clip, source);
140
163
  return;
164
+ }
141
165
  const previous = acceptances.get(entry.id);
142
166
  if (previous !== undefined) {
143
167
  if (previous.clip.clip_id !== clip.clip_id)
144
- fail(new ReactorError("Protocol", "H3 acceptance identity named two clips", {
168
+ fail(ReactorError.fromCode("Protocol", "H3 acceptance identity named two clips", {
145
169
  operation: "enqueue",
146
170
  }));
147
171
  return;
148
172
  }
149
173
  if (acceptances.size >= limits.acceptances) {
150
- const first = acceptances.keys().next();
151
- if (!first.done)
152
- acceptances.delete(first.value);
174
+ const first = acceptances.entries().next();
175
+ if (!first.done) {
176
+ acceptances.delete(first.value[0]);
177
+ retain(first.value[1], -1);
178
+ }
153
179
  }
154
180
  acceptances.set(entry.id, acceptance);
181
+ retain(acceptance, 1);
182
+ operations.accept(acceptance);
155
183
  Deferred.doneUnsafe(entry.deferred, Effect.succeed(acceptance));
156
184
  emit({ _tag: "Acceptance", acceptance });
157
185
  };
@@ -159,15 +187,13 @@ const build = (session, options) => Effect.gen(function* () {
159
187
  if (closed || fatalError !== undefined)
160
188
  return;
161
189
  try {
162
- const before = state.snapshot().transportGeneration;
190
+ const before = state.transportGeneration;
163
191
  let disposition = state.admit(source);
164
- if (state.snapshot().transportGeneration !== before) {
192
+ if (state.transportGeneration !== before) {
165
193
  uploads.clear();
166
194
  for (const entry of pending.values())
167
195
  if (entry.generation !== source.generation)
168
- Deferred.doneUnsafe(entry.deferred, Effect.fail(new ReactorError("Disconnected", "H3 acceptance crossed a transport generation", {
169
- operation: "enqueue",
170
- })));
196
+ Deferred.doneUnsafe(entry.deferred, Effect.fail(ReactorError.fromCode("Disconnected", "H3 acceptance crossed a transport generation", { operation: "enqueue" })));
171
197
  }
172
198
  if (source._tag !== "Model") {
173
199
  emit({ _tag: "Session", source });
@@ -177,7 +203,7 @@ const build = (session, options) => Effect.gen(function* () {
177
203
  if (source.kind === "ack") {
178
204
  result =
179
205
  disposition === "stale"
180
- ? Result.fail(new ReactorError("Disconnected", "H3 received a stale command acknowledgement"))
206
+ ? Result.fail(ReactorError.fromCode("Disconnected", "H3 received a stale command acknowledgement"))
181
207
  : Result.succeed(undefined);
182
208
  emit({ _tag: "Acknowledged", source });
183
209
  }
@@ -187,7 +213,7 @@ const build = (session, options) => Effect.gen(function* () {
187
213
  disposition = state.apply(message, source);
188
214
  result =
189
215
  disposition === "stale"
190
- ? Result.fail(new ReactorError("Disconnected", "H3 received a stale command message"))
216
+ ? Result.fail(ReactorError.fromCode("Disconnected", "H3 received a stale command message"))
191
217
  : Result.succeed(message);
192
218
  if (disposition !== "stale" && message.type !== "unknown") {
193
219
  if (message.type === "queue_update") {
@@ -200,6 +226,7 @@ const build = (session, options) => Effect.gen(function* () {
200
226
  }
201
227
  else if ("clip" in message.data)
202
228
  accept(message.data.clip, source);
229
+ operations.observe(message, source);
203
230
  }
204
231
  emit({ _tag: "Message", message, source, disposition });
205
232
  }
@@ -207,14 +234,17 @@ const build = (session, options) => Effect.gen(function* () {
207
234
  const waiter = observedWaiters.get(source);
208
235
  if (waiter !== undefined)
209
236
  Deferred.doneUnsafe(waiter, Result.isSuccess(result) ? Effect.succeed(result.success) : Effect.fail(result.failure));
210
- if (sizeOf({ snapshot: state.snapshot(), acceptances: [...acceptances.values()] }) >
211
- limits.retained)
212
- fail(new ReactorError("Overflow", "H3 retained observation byte bound exceeded", {
237
+ if (state.retained.bytes > limits.retained)
238
+ fail(ReactorError.fromCode("Overflow", "H3 retained observation byte bound exceeded", {
213
239
  operation: "H3 observation",
214
240
  }));
215
241
  }
216
242
  catch (cause) {
217
- const error = errorOf(cause);
243
+ // The reducer rejects provider data with a ReactorError; anything else
244
+ // it throws is a bug, which stays a defect of the observation fiber.
245
+ if (!ReactorError.is(cause))
246
+ throw cause;
247
+ const error = cause;
218
248
  if (source._tag === "Model") {
219
249
  decoded.set(source, Result.fail(error));
220
250
  const waiter = observedWaiters.get(source);
@@ -224,19 +254,43 @@ const build = (session, options) => Effect.gen(function* () {
224
254
  fail(error);
225
255
  }
226
256
  };
227
- yield* observation.events.pipe(Stream.runForEach((source) => Effect.sync(() => reduce(source))), Effect.catch((error) => Effect.sync(() => fail(error))), Effect.andThen(Effect.sync(() => {
257
+ yield* observation.events.pipe(Stream.runForEach((source) => Effect.sync(() => {
258
+ reduce(source);
259
+ wake();
260
+ })), Effect.catch((error) => Effect.sync(() => fail(error))), Effect.andThen(Effect.sync(() => {
228
261
  if (!closed)
229
- fail(new ReactorError("Closed", "H3 session observation ended"));
262
+ fail(ReactorError.fromCode("Closed", "H3 session observation ended"));
230
263
  })), Effect.forkScoped);
231
- const active = (operation, needsFacts) => Effect.suspend(() => {
232
- const current = state.snapshot();
233
- const error = closed
234
- ? new ReactorError("Closed", "H3 provider scope is closed")
235
- : (fatalError ??
236
- (needsFacts && current._tag !== "Ready"
237
- ? new ReactorError("InvalidState", "H3 provider needs current state and queue observations")
238
- : undefined));
239
- return error === undefined ? Effect.void : Effect.fail(localFailure(operation, error));
264
+ const refusal = (needsFacts) => closed
265
+ ? ReactorError.fromCode("Closed", "H3 provider scope is closed")
266
+ : (fatalError ??
267
+ (needsFacts && state.availability !== "Ready"
268
+ ? ReactorError.fromCode("InvalidState", "H3 provider needs current state and queue observations")
269
+ : undefined));
270
+ /**
271
+ * H3 replies to a command before it broadcasts the state and queue the
272
+ * command changed, so the provider is briefly synchronizing after every
273
+ * reply. A command that needs current facts waits, within its own reply
274
+ * deadline, for those broadcasts; it is refused, not submitted, only if
275
+ * they do not come or the provider fails.
276
+ */
277
+ const synchronized = Effect.gen(function* () {
278
+ while (!closed && fatalError === undefined && state.availability === "Synchronizing") {
279
+ const waiter = Deferred.makeUnsafe();
280
+ synchronizing.add(waiter);
281
+ yield* Deferred.await(waiter).pipe(Effect.ensuring(Effect.sync(() => synchronizing.delete(waiter))));
282
+ }
283
+ });
284
+ /** Wait, within one reply deadline, while the provider is only synchronizing. */
285
+ const settled = Effect.suspend(() => refusal(false) === undefined && state.availability === "Synchronizing"
286
+ ? synchronized.pipe(Effect.timeout(limits.command), Effect.ignore)
287
+ : Effect.void);
288
+ const active = (operation, needsFacts) => Effect.gen(function* () {
289
+ if (needsFacts)
290
+ yield* settled;
291
+ const error = refusal(needsFacts);
292
+ if (error !== undefined)
293
+ return yield* localFailure(operation, error);
240
294
  });
241
295
  const awaitObservation = (source) => Effect.suspend(() => {
242
296
  const previous = decoded.get(source);
@@ -247,28 +301,31 @@ const build = (session, options) => Effect.gen(function* () {
247
301
  if (fatalError !== undefined)
248
302
  return Effect.fail(fatalError);
249
303
  if (observedWaiters.size >= limits.pending)
250
- return Effect.fail(new ReactorError("Overflow", "H3 reply observer bound exceeded"));
304
+ return Effect.fail(ReactorError.fromCode("Overflow", "H3 reply observer bound exceeded"));
251
305
  const waiter = Deferred.makeUnsafe();
252
306
  observedWaiters.set(source, waiter);
253
307
  return Deferred.await(waiter).pipe(Effect.timeoutOrElse({
254
308
  duration: limits.command,
255
- orElse: () => Effect.fail(new ReactorError("Timeout", "H3 did not observe the command's exact returned envelope")),
309
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "H3 did not observe the command's exact returned envelope")),
256
310
  }), Effect.ensuring(Effect.sync(() => {
257
311
  observedWaiters.delete(source);
258
312
  })));
259
313
  });
260
314
  const call = (operation, args, needsFacts = true) => Effect.gen(function* () {
261
315
  yield* active(operation, needsFacts);
262
- const source = yield* session.command(operation, args, undefined, limits.command);
316
+ const source = yield* session.command(operation, args, { replyTimeout: limits.command });
263
317
  const message = yield* awaitObservation(source).pipe(Effect.mapError((error) => uncertain(operation, source, "H3 reply observation failed", error)));
264
318
  if (message?.type === "command_error" && message.data.command === operation)
265
- return yield* Effect.fail(rejected(operation, source));
319
+ return yield* rejected(operation, source, message.data.reason);
266
320
  return { source, message };
267
321
  });
268
322
  const named = (operation, args, needsFacts = true) => {
269
323
  const expected = Commands[operation].reply;
270
324
  return call(operation, args, needsFacts).pipe(Effect.flatMap(({ source, message }) => message?.type === expected
271
- ? Effect.succeed(Object.freeze({ value: message.data, source }))
325
+ ? // The broadcasts a reply implies follow it: the command settles once
326
+ // they are observed, so its caller reads its own effects. The state
327
+ // and queue reads are themselves that barrier and do not wait.
328
+ (needsFacts ? settled : Effect.void).pipe(Effect.as(Object.freeze({ value: message.data, source })))
272
329
  : Effect.fail(uncertain(operation, source, `H3 ${operation} did not return ${expected}`))));
273
330
  };
274
331
  const checked = (operation, value) => pure(value).pipe(Effect.mapError((error) => localFailure(operation, error)));
@@ -276,7 +333,7 @@ const build = (session, options) => Effect.gen(function* () {
276
333
  const getQueue = named("get_queue", {}, false);
277
334
  const refresh = getState.pipe(Effect.andThen(getQueue), Effect.flatMap((reply) => state.snapshot()._tag === "Ready"
278
335
  ? Effect.void
279
- : Effect.fail(new CommandFailure(new ReactorError("InvalidState", "H3 full snapshots changed during refresh"), {
336
+ : Effect.fail(CommandFailure.from(ReactorError.fromCode("InvalidState", "H3 full snapshots changed during refresh"), {
280
337
  operation: "H3 refresh",
281
338
  outcome: "replied",
282
339
  requestId: reply.source.requestId,
@@ -285,13 +342,13 @@ const build = (session, options) => Effect.gen(function* () {
285
342
  const control = (operation, args) => call(operation, args).pipe(Effect.flatMap(({ source, message }) => source.kind === "ack"
286
343
  ? Effect.succeed(Object.freeze({ _tag: "Acknowledged", source }))
287
344
  : message !== undefined && message.type !== "unknown"
288
- ? Effect.succeed(Object.freeze({ _tag: "Reply", message, source }))
345
+ ? settled.pipe(Effect.as(Object.freeze({ _tag: "Reply", message, source })))
289
346
  : Effect.fail(uncertain(operation, source, "Unexpected H3 control response"))));
290
347
  const stage = (request, metadata) => Effect.gen(function* () {
291
348
  yield* active("enqueue", true);
292
349
  const before = state.snapshot();
293
350
  if (before._tag !== "Ready")
294
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("InvalidState", "H3 is not ready")));
351
+ return yield* localFailure("enqueue", ReactorError.fromCode("InvalidState", "H3 is not ready"));
295
352
  if (request.seconds !== undefined)
296
353
  yield* checked("enqueue", () => seconds(request.seconds, {
297
354
  min: before.state.clip_seconds_min,
@@ -306,20 +363,22 @@ const build = (session, options) => Effect.gen(function* () {
306
363
  }
307
364
  const digest = yield* crypto
308
365
  .digest("SHA-256", material.bytes)
309
- .pipe(Effect.mapError(() => localFailure("enqueue", new ReactorError("InvalidState", "Could not hash H3 reference"))));
366
+ .pipe(Effect.mapError(() => localFailure("enqueue", ReactorError.fromCode("InvalidState", "Could not hash H3 reference"))));
310
367
  const key = `${before.transportGeneration}:${hex(digest)}`;
311
368
  const file = yield* uploadGate.withPermit(Effect.gen(function* () {
312
369
  const cached = uploads.get(key);
313
370
  if (cached !== undefined)
314
371
  return cached;
315
372
  const uploaded = yield* session
316
- .upload(`h3-${hex(digest)}`, reference.mimeType, material.bytes, limits.command)
373
+ .upload(`h3-${hex(digest)}`, reference.mimeType, material.bytes, {
374
+ uploadTimeout: limits.upload,
375
+ })
317
376
  .pipe(Effect.mapError((error) => localFailure("enqueue", error)));
318
377
  const file = yield* checked("enqueue", () => checkedUpload(uploaded.file));
319
378
  if (file.size !== BigInt(reference.size) || file.mime_type !== reference.mimeType)
320
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("Protocol", "H3 upload returned different file facts")));
379
+ return yield* localFailure("enqueue", ReactorError.fromCode("Protocol", "H3 upload returned different file facts"));
321
380
  if (state.snapshot().transportGeneration !== before.transportGeneration)
322
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("Disconnected", "H3 reference upload crossed a generation")));
381
+ return yield* localFailure("enqueue", ReactorError.fromCode("Disconnected", "H3 reference upload crossed a generation"));
323
382
  if (uploads.size >= limits.cache) {
324
383
  const first = uploads.keys().next();
325
384
  if (!first.done)
@@ -335,7 +394,7 @@ const build = (session, options) => Effect.gen(function* () {
335
394
  });
336
395
  const nextId = () => pure(() => {
337
396
  if (counter === 0xffffffffffffffffn)
338
- throw new ReactorError("Overflow", "H3 submission identity space exhausted", {
397
+ throw ReactorError.fromCode("Overflow", "H3 submission identity space exhausted", {
339
398
  outcome: "not-submitted",
340
399
  });
341
400
  return `${namespace}:${++counter}`;
@@ -343,7 +402,7 @@ const build = (session, options) => Effect.gen(function* () {
343
402
  const prepared = (id, input, hooks) => Submission.make({
344
403
  id,
345
404
  prepare: Effect.gen(function* () {
346
- const { request, metadata } = yield* input.pipe(Effect.mapError((error) => localFailure("enqueue", error)));
405
+ const { request, metadata } = yield* input.pipe(Effect.mapError((error) => preparationFailure("enqueue", error)));
347
406
  const staged = yield* stage(request, metadata);
348
407
  return {
349
408
  ...staged,
@@ -361,9 +420,11 @@ const build = (session, options) => Effect.gen(function* () {
361
420
  const live = yield* session.ready.pipe(Effect.mapError((error) => localFailure("enqueue", error)));
362
421
  if (entry.generation !== state.snapshot().transportGeneration ||
363
422
  live.generation !== entry.generation)
364
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("Disconnected", "H3 prepared input crossed a transport generation")));
423
+ return yield* localFailure("enqueue", ReactorError.fromCode("Disconnected", "H3 prepared input crossed a transport generation"));
365
424
  if (pending.size >= limits.pending)
366
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("Overflow", "H3 pending acceptance bound reached")));
425
+ return yield* localFailure("enqueue", ReactorError.fromCode("Overflow", "H3 pending acceptance bound reached"));
426
+ // The operation's slot is taken before anything is sent.
427
+ yield* pure(() => operations.reserve(entry)).pipe(Effect.mapError((error) => localFailure("enqueue", error)));
367
428
  // Acceptance registration is inside the same commit boundary as the
368
429
  // orchestration hook and precedes Session.command's wire correlation.
369
430
  pending.set(id, entry);
@@ -371,16 +432,17 @@ const build = (session, options) => Effect.gen(function* () {
371
432
  yield* hooks.commit(id).pipe(Effect.onExit((exit) => Exit.isFailure(exit)
372
433
  ? Effect.sync(() => {
373
434
  pending.delete(id);
435
+ operations.abandon(id);
374
436
  })
375
437
  : Effect.void));
376
438
  }),
377
439
  execute: ({ args, entry }) => {
378
440
  const execution = Effect.gen(function* () {
379
- const sent = yield* Effect.result(session.command("enqueue", args, undefined, limits.command));
441
+ const sent = yield* Effect.result(session.command("enqueue", args, { replyTimeout: limits.command }));
380
442
  let original;
381
443
  if (Result.isFailure(sent)) {
382
444
  if (sent.failure.context.outcome !== "unknown")
383
- return yield* Effect.fail(sent.failure);
445
+ return yield* sent.failure;
384
446
  original = sent.failure;
385
447
  }
386
448
  else {
@@ -389,29 +451,48 @@ const build = (session, options) => Effect.gen(function* () {
389
451
  if (Result.isSuccess(observed) &&
390
452
  observed.success?.type === "command_error" &&
391
453
  observed.success.data.command === "enqueue")
392
- return yield* Effect.fail(rejected("enqueue", source));
454
+ return yield* rejected("enqueue", source, observed.success.data.reason);
393
455
  original = uncertain("enqueue", source, "H3 enqueue has no proven clip acceptance", Result.isFailure(observed) ? observed.failure : undefined);
394
456
  }
395
- return yield* Deferred.await(entry.deferred).pipe(Effect.timeoutOrElse({
457
+ const acceptance = yield* Deferred.await(entry.deferred).pipe(Effect.timeoutOrElse({
396
458
  duration: limits.reconcile,
397
459
  orElse: () => Effect.fail(original),
398
- }), Effect.mapError(() => original));
460
+ }), Effect.mapError(() => original), Effect.withSpan("reactor.h3.reconcile", {}, { captureStackTrace: false }));
461
+ // Like every command, an enqueue settles once the snapshots its reply
462
+ // implies are observed, so its caller finds the clip in the queue.
463
+ yield* settled;
464
+ return acceptance;
399
465
  }).pipe(Effect.ensuring(Effect.sync(() => {
400
466
  pending.delete(id);
401
- })));
467
+ })), Effect.onExit((exit) => Effect.sync(() => {
468
+ operations.settle(id, exit);
469
+ })),
470
+ // The submission's own execution fiber carries the span, so it ends with
471
+ // the enqueue's outcome even when the caller stopped waiting.
472
+ Effect.onExit((exit) => {
473
+ const error = Exit.findError(exit);
474
+ return Effect.annotateCurrentSpan(Exit.isSuccess(exit)
475
+ ? { "reactor.command.outcome": "replied" }
476
+ : error._tag === "Success"
477
+ ? {
478
+ "reactor.command.outcome": error.success.context.outcome,
479
+ "error.type": error.success.reason._tag,
480
+ }
481
+ : {});
482
+ }), Effect.withSpan("reactor.h3.enqueue", { kind: "client", attributes: { "reactor.h3.submission.id": id } }, { captureStackTrace: false }));
402
483
  return hooks.result === undefined
403
484
  ? execution
404
485
  : Effect.result(execution).pipe(Effect.flatMap((result) => Effect.gen(function* () {
405
486
  const hook = yield* Effect.exit(Effect.suspend(() => hooks.result(id, result)).pipe(Effect.interruptible, Effect.timeoutOrElse({
406
487
  duration: limits.hook,
407
- orElse: () => Effect.fail(new ReactorError("Timeout", "H3 result hook exceeded its deadline")),
488
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "H3 result hook exceeded its deadline")),
408
489
  })));
409
490
  // Preserve the entire secondary Cause for deliberate inspection,
410
491
  // including defects; never substitute it for primary remote evidence.
411
492
  if (Exit.isFailure(hook))
412
493
  emit({
413
494
  _tag: "Diagnostic",
414
- error: new ReactorError("InvalidState", "H3 result hook failed", {
495
+ error: ReactorError.fromCode("InvalidState", "H3 result hook failed", {
415
496
  operation: "H3 result hook",
416
497
  detail: hook.cause,
417
498
  }),
@@ -432,9 +513,9 @@ const build = (session, options) => Effect.gen(function* () {
432
513
  const prepareFrom = (preparation, hooks = {}) => Effect.gen(function* () {
433
514
  yield* active("enqueue", true);
434
515
  if (!Effect.isEffect(preparation))
435
- return yield* Effect.fail(new ReactorError("InvalidInput", "H3 preparation must be an Effect", {
516
+ return yield* ReactorError.fromCode("InvalidInput", "H3 preparation must be an Effect", {
436
517
  outcome: "not-submitted",
437
- }));
518
+ });
438
519
  const id = yield* nextId();
439
520
  return yield* prepared(id, preparation.pipe(Effect.flatMap((input) => pure(() => {
440
521
  const request = captureRequest(input, limits.prompt);
@@ -443,14 +524,15 @@ const build = (session, options) => Effect.gen(function* () {
443
524
  });
444
525
  const contract = yield* session.schema.pipe(Effect.flatMap(({ openapi }) => pure(() => validateDeployment(openapi))), Effect.timeoutOrElse({
445
526
  duration: limits.setup,
446
- orElse: () => Effect.fail(new ReactorError("Timeout", "H3 schema read timed out")),
527
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "H3 schema read timed out")),
447
528
  }));
448
529
  yield* refresh.pipe(Effect.timeoutOrElse({
449
530
  duration: limits.setup,
450
- orElse: () => Effect.fail(new ReactorError("Timeout", "H3 initial state and queue reads timed out")),
531
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "H3 initial state and queue reads timed out")),
451
532
  }));
452
533
  if (state.snapshot()._tag !== "Ready")
453
- return yield* Effect.fail(fatalError ?? new ReactorError("InvalidState", "H3 initial observations are unavailable"));
534
+ return yield* (fatalError ??
535
+ ReactorError.fromCode("InvalidState", "H3 initial observations are unavailable"));
454
536
  const provider = {
455
537
  sessionId: session.id,
456
538
  contract,
@@ -458,44 +540,47 @@ const build = (session, options) => Effect.gen(function* () {
458
540
  observe: (bounds) => Effect.gen(function* () {
459
541
  const stream = yield* events.subscribe(bounds);
460
542
  const initial = state.snapshot();
461
- return { initial, revision: initial.revision, events: stream };
543
+ // The reducer may apply a queued event between the subscription and
544
+ // this read; the snapshot then covers it, so it is not repeated.
545
+ return {
546
+ initial,
547
+ revision: initial.revision,
548
+ events: Stream.filter(stream, (event) => !covered(event, initial.revision)),
549
+ };
462
550
  }),
463
551
  events: (bounds) => events.stream(bounds),
464
552
  failure: Deferred.await(fatal),
465
553
  acceptances: Effect.sync(() => Object.freeze([...acceptances.values()])),
466
554
  acceptance: (id) => Effect.sync(() => acceptances.get(id)),
555
+ operation: (submission) => operations.attach(submission.id),
467
556
  prepare,
468
557
  prepareFrom,
469
- enqueue: (request) => prepare(request).pipe(Effect.mapError((error) => localFailure("enqueue", error)), Effect.flatMap((submission) => submission.submit)),
558
+ enqueue: (request) => prepare(request).pipe(Effect.mapError((error) => ReactorError.is(error) ? localFailure("enqueue", error) : error), Effect.flatMap((submission) => submission.submit)),
470
559
  getState,
471
560
  getQueue,
472
561
  refresh,
473
- pop: (id) => checked("pop", () => clipId(id)).pipe(Effect.flatMap((id) => named("pop", { clip_id: id })), Effect.flatMap((reply) => reply.value.clip.clip_id === id
474
- ? Effect.succeed(reply)
475
- : Effect.fail(uncertain("pop", reply.source, "H3 pop returned a different clip")))),
562
+ pop: (id) => checked("pop", () => clipId(id)).pipe(Effect.flatMap((id) => named("pop", { clip_id: id })), Effect.filterOrFail((reply) => reply.value.clip.clip_id === id, (reply) => uncertain("pop", reply.source, "H3 pop returned a different clip"))),
476
563
  move: (id, position) => checked("move", () => ({
477
564
  clip_id: clipId(id),
478
565
  position: nonnegative(position, "position"),
479
- })).pipe(Effect.flatMap((args) => named("move", args)), Effect.flatMap((reply) => reply.value.clip.clip_id === id
480
- ? Effect.succeed(reply)
481
- : Effect.fail(uncertain("move", reply.source, "H3 move returned a different clip")))),
566
+ })).pipe(Effect.flatMap((args) => named("move", args)), Effect.filterOrFail((reply) => reply.value.clip.clip_id === id, (reply) => uncertain("move", reply.source, "H3 move returned a different clip"))),
482
567
  play: (id) => checked("play", () => ({ clip_id: id === undefined ? "" : clipId(id) })).pipe(Effect.flatMap((args) => control("play", args))),
483
568
  stop: control("stop", {}),
484
569
  setSeed: (value) => checked("set_seed", () => nonnegative(value, "seed")).pipe(Effect.flatMap((seed) => named("set_seed", { seed }))),
485
570
  setClipSeconds: (value) => checked("set_clip_seconds", () => seconds(value)).pipe(Effect.flatMap((seconds) => named("set_clip_seconds", { seconds }))),
486
571
  setCanvas: (aspect) => checked("set_canvas", () => {
487
572
  if (!Object.hasOwn(canvases, aspect))
488
- throw new ReactorError("InvalidInput", "Unsupported H3 canvas aspect");
573
+ throw ReactorError.fromCode("InvalidInput", "Unsupported H3 canvas aspect");
489
574
  return { aspect };
490
575
  }).pipe(Effect.flatMap((args) => named("set_canvas", args))),
491
576
  setAutoplay: (enabled) => checked("set_autoplay", () => {
492
577
  if (typeof enabled !== "boolean")
493
- throw new ReactorError("InvalidInput", "Autoplay must be boolean");
578
+ throw ReactorError.fromCode("InvalidInput", "Autoplay must be boolean");
494
579
  return { enabled };
495
580
  }).pipe(Effect.flatMap((args) => named("set_autoplay", args))),
496
581
  setFlushOnClipEnd: (enabled) => checked("set_flush_on_clip_end", () => {
497
582
  if (typeof enabled !== "boolean")
498
- throw new ReactorError("InvalidInput", "Flush setting must be boolean");
583
+ throw ReactorError.fromCode("InvalidInput", "Flush setting must be boolean");
499
584
  return { enabled };
500
585
  }).pipe(Effect.flatMap((args) => named("set_flush_on_clip_end", args))),
501
586
  reset: named("reset", {}),