reactor-effect-client 0.2.0 → 0.3.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 (219) hide show
  1. package/README.md +66 -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 +172 -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 +280 -146
  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 +58 -0
  67. package/dist/h3/_internal/operations.d.ts.map +1 -0
  68. package/dist/h3/_internal/operations.js +268 -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 +109 -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 +213 -110
  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 +19 -0
  203. package/dist/stats.d.ts.map +1 -1
  204. package/dist/stats.js +79 -8
  205. package/dist/stats.js.map +1 -1
  206. package/package.json +5 -4
  207. package/notices/owned-webrtc/Effect-MIT.txt +0 -21
  208. package/notices/owned-webrtc/IETF.txt +0 -43
  209. package/notices/owned-webrtc/LICENSE +0 -21
  210. package/notices/owned-webrtc/UPSTREAM.md +0 -20
  211. package/notices/owned-webrtc/VP8-ADAPTATION.md +0 -32
  212. package/notices/owned-webrtc/VP8-AUTHORS.txt +0 -42
  213. package/notices/owned-webrtc/VP8-LICENSE.txt +0 -25
  214. package/notices/owned-webrtc/VP8-PATENTS.txt +0 -21
  215. package/notices/owned-webrtc/Werift-MIT.txt +0 -21
  216. package/notices/owned-webrtc/libvpx-v1.12.0-LICENSE.txt +0 -28
  217. package/notices/owned-webrtc/libvpx-v1.12.0-PATENTS.txt +0 -22
  218. package/notices/owned-webrtc/rfc3711.txt +0 -3139
  219. 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,26 +98,83 @@ 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));
129
+ /** Decide a submission's acceptance: record it, resolve its waiters and announce it. */
130
+ const record = (entry, acceptance) => {
131
+ entry.held = undefined;
132
+ if (acceptances.size >= limits.acceptances) {
133
+ const first = acceptances.entries().next();
134
+ if (!first.done) {
135
+ acceptances.delete(first.value[0]);
136
+ retain(first.value[1], -1);
137
+ }
138
+ }
139
+ acceptances.set(entry.id, acceptance);
140
+ retain(acceptance, 1);
141
+ operations.accept(acceptance);
142
+ Deferred.doneUnsafe(entry.deferred, Effect.succeed(acceptance));
143
+ emit({ _tag: "Acceptance", acceptance });
144
+ };
145
+ /**
146
+ * Held evidence decides the acceptance once the reply cannot: it came
147
+ * without correlating the clip, or the wait for it ended short of a
148
+ * definite failure, which discards the evidence instead.
149
+ */
150
+ const decideHeld = (entry) => {
151
+ if (entry.held !== undefined)
152
+ record(entry, entry.held);
153
+ };
111
154
  const fail = (error) => {
112
155
  if (fatalError !== undefined)
113
156
  return;
114
157
  fatalError = error;
115
158
  state.unavailable(error);
159
+ // Recording held evidence resumes its waiters at once: by now the provider
160
+ // refuses their new work, and they can read the acceptance before anyone
161
+ // learns of the failure.
162
+ for (const entry of pending.values())
163
+ decideHeld(entry);
116
164
  Deferred.doneUnsafe(fatal, Effect.succeed(error));
117
165
  for (const entry of pending.values())
118
166
  Deferred.doneUnsafe(entry.deferred, Effect.fail(error));
119
167
  for (const waiter of observedWaiters.values())
120
168
  Deferred.doneUnsafe(waiter, Effect.fail(error));
121
169
  emit({ _tag: "Diagnostic", error });
170
+ wake();
122
171
  };
123
172
  yield* Effect.addFinalizer(() => Effect.sync(() => {
124
173
  closed = true;
125
- fail(new ReactorError("Closed", "H3 provider scope closed", { operation: "H3 observation" }));
174
+ fail(ReactorError.fromCode("Closed", "H3 provider scope closed", {
175
+ operation: "H3 observation",
176
+ }));
177
+ operations.retire();
126
178
  events.end();
127
179
  pending.clear();
128
180
  observedWaiters.clear();
@@ -133,41 +185,47 @@ const build = (session, options) => Effect.gen(function* () {
133
185
  if (id === undefined)
134
186
  return;
135
187
  const entry = pending.get(id);
136
- if (entry === undefined)
137
- return;
138
- const acceptance = acceptanceFor(entry, clip, source);
139
- if (acceptance === undefined)
188
+ const acceptance = entry === undefined ? undefined : acceptanceFor(entry, clip, source);
189
+ if (entry === undefined || acceptance === undefined) {
190
+ // After the reconcile window, or in a later transport generation, the
191
+ // evidence can still resolve the operation, never the acceptances.
192
+ operations.lateEvidence(id, clip, source);
140
193
  return;
141
- const previous = acceptances.get(entry.id);
142
- if (previous !== undefined) {
143
- if (previous.clip.clip_id !== clip.clip_id)
144
- fail(new ReactorError("Protocol", "H3 acceptance identity named two clips", {
145
- operation: "enqueue",
146
- }));
194
+ }
195
+ const previous = acceptances.get(entry.id) ?? entry.held;
196
+ if (previous !== undefined && previous.clip.clip_id !== clip.clip_id) {
197
+ fail(ReactorError.fromCode("Protocol", "H3 acceptance identity named two clips", {
198
+ operation: "enqueue",
199
+ }));
147
200
  return;
148
201
  }
149
- if (acceptances.size >= limits.acceptances) {
150
- const first = acceptances.keys().next();
151
- if (!first.done)
152
- acceptances.delete(first.value);
202
+ if (acceptances.has(entry.id))
203
+ return;
204
+ // Until the reply is observed, it may still correlate the acceptance; the
205
+ // clip's facts are recorded meanwhile.
206
+ if (acceptance.evidence.kind === "metadata" && entry.awaiting) {
207
+ if (entry.held === undefined) {
208
+ entry.held = acceptance;
209
+ operations.identify(acceptance);
210
+ }
211
+ return;
153
212
  }
154
- acceptances.set(entry.id, acceptance);
155
- Deferred.doneUnsafe(entry.deferred, Effect.succeed(acceptance));
156
- emit({ _tag: "Acceptance", acceptance });
213
+ record(entry, acceptance);
157
214
  };
158
215
  const reduce = (source) => {
159
216
  if (closed || fatalError !== undefined)
160
217
  return;
161
218
  try {
162
- const before = state.snapshot().transportGeneration;
219
+ const before = state.transportGeneration;
163
220
  let disposition = state.admit(source);
164
- if (state.snapshot().transportGeneration !== before) {
221
+ if (state.transportGeneration !== before) {
165
222
  uploads.clear();
166
223
  for (const entry of pending.values())
167
- 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
- })));
224
+ if (entry.generation !== source.generation) {
225
+ // No reply to an earlier generation's enqueue can correlate now.
226
+ decideHeld(entry);
227
+ Deferred.doneUnsafe(entry.deferred, Effect.fail(ReactorError.fromCode("Disconnected", "H3 acceptance crossed a transport generation", { operation: "enqueue" })));
228
+ }
171
229
  }
172
230
  if (source._tag !== "Model") {
173
231
  emit({ _tag: "Session", source });
@@ -177,7 +235,7 @@ const build = (session, options) => Effect.gen(function* () {
177
235
  if (source.kind === "ack") {
178
236
  result =
179
237
  disposition === "stale"
180
- ? Result.fail(new ReactorError("Disconnected", "H3 received a stale command acknowledgement"))
238
+ ? Result.fail(ReactorError.fromCode("Disconnected", "H3 received a stale command acknowledgement"))
181
239
  : Result.succeed(undefined);
182
240
  emit({ _tag: "Acknowledged", source });
183
241
  }
@@ -187,7 +245,7 @@ const build = (session, options) => Effect.gen(function* () {
187
245
  disposition = state.apply(message, source);
188
246
  result =
189
247
  disposition === "stale"
190
- ? Result.fail(new ReactorError("Disconnected", "H3 received a stale command message"))
248
+ ? Result.fail(ReactorError.fromCode("Disconnected", "H3 received a stale command message"))
191
249
  : Result.succeed(message);
192
250
  if (disposition !== "stale" && message.type !== "unknown") {
193
251
  if (message.type === "queue_update") {
@@ -200,6 +258,7 @@ const build = (session, options) => Effect.gen(function* () {
200
258
  }
201
259
  else if ("clip" in message.data)
202
260
  accept(message.data.clip, source);
261
+ operations.observe(message, source);
203
262
  }
204
263
  emit({ _tag: "Message", message, source, disposition });
205
264
  }
@@ -207,14 +266,17 @@ const build = (session, options) => Effect.gen(function* () {
207
266
  const waiter = observedWaiters.get(source);
208
267
  if (waiter !== undefined)
209
268
  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", {
269
+ if (state.retained.bytes > limits.retained)
270
+ fail(ReactorError.fromCode("Overflow", "H3 retained observation byte bound exceeded", {
213
271
  operation: "H3 observation",
214
272
  }));
215
273
  }
216
274
  catch (cause) {
217
- const error = errorOf(cause);
275
+ // The reducer rejects provider data with a ReactorError; anything else
276
+ // it throws is a bug, which stays a defect of the observation fiber.
277
+ if (!ReactorError.is(cause))
278
+ throw cause;
279
+ const error = cause;
218
280
  if (source._tag === "Model") {
219
281
  decoded.set(source, Result.fail(error));
220
282
  const waiter = observedWaiters.get(source);
@@ -224,19 +286,50 @@ const build = (session, options) => Effect.gen(function* () {
224
286
  fail(error);
225
287
  }
226
288
  };
227
- yield* observation.events.pipe(Stream.runForEach((source) => Effect.sync(() => reduce(source))), Effect.catch((error) => Effect.sync(() => fail(error))), Effect.andThen(Effect.sync(() => {
289
+ yield* observation.events.pipe(Stream.runForEach((source) => Effect.sync(() => {
290
+ reduce(source);
291
+ wake();
292
+ })), Effect.catch((error) => Effect.sync(() => fail(error))), Effect.andThen(Effect.sync(() => {
228
293
  if (!closed)
229
- fail(new ReactorError("Closed", "H3 session observation ended"));
294
+ fail(ReactorError.fromCode("Closed", "H3 session observation ended"));
230
295
  })), 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));
296
+ // Enqueues execute in a scope of their own. Finalizers run last-added first,
297
+ // so a closing provider refuses new work before that scope interrupts them.
298
+ const executions = yield* Scope.fork(scope);
299
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
300
+ closed = true;
301
+ }));
302
+ const refusal = (needsFacts) => closed
303
+ ? ReactorError.fromCode("Closed", "H3 provider scope is closed")
304
+ : (fatalError ??
305
+ (needsFacts && state.availability !== "Ready"
306
+ ? ReactorError.fromCode("InvalidState", "H3 provider needs current state and queue observations")
307
+ : undefined));
308
+ /**
309
+ * H3 documents that a command replies before it broadcasts the state and
310
+ * queue it changed (hosted H3 broadcasts an enqueue's queue first), so the
311
+ * provider may be briefly synchronizing after a reply. A command that needs
312
+ * current facts waits, within its own reply deadline, for those
313
+ * broadcasts; it is refused, not submitted, only if they do not come or
314
+ * the provider fails.
315
+ */
316
+ const synchronized = Effect.gen(function* () {
317
+ while (!closed && fatalError === undefined && state.availability === "Synchronizing") {
318
+ const waiter = Deferred.makeUnsafe();
319
+ synchronizing.add(waiter);
320
+ yield* Deferred.await(waiter).pipe(Effect.ensuring(Effect.sync(() => synchronizing.delete(waiter))));
321
+ }
322
+ });
323
+ /** Wait, within one reply deadline, while the provider is only synchronizing. */
324
+ const settled = Effect.suspend(() => refusal(false) === undefined && state.availability === "Synchronizing"
325
+ ? synchronized.pipe(Effect.timeout(limits.command), Effect.ignore)
326
+ : Effect.void);
327
+ const active = (operation, needsFacts) => Effect.gen(function* () {
328
+ if (needsFacts)
329
+ yield* settled;
330
+ const error = refusal(needsFacts);
331
+ if (error !== undefined)
332
+ return yield* localFailure(operation, error);
240
333
  });
241
334
  const awaitObservation = (source) => Effect.suspend(() => {
242
335
  const previous = decoded.get(source);
@@ -247,28 +340,31 @@ const build = (session, options) => Effect.gen(function* () {
247
340
  if (fatalError !== undefined)
248
341
  return Effect.fail(fatalError);
249
342
  if (observedWaiters.size >= limits.pending)
250
- return Effect.fail(new ReactorError("Overflow", "H3 reply observer bound exceeded"));
343
+ return Effect.fail(ReactorError.fromCode("Overflow", "H3 reply observer bound exceeded"));
251
344
  const waiter = Deferred.makeUnsafe();
252
345
  observedWaiters.set(source, waiter);
253
346
  return Deferred.await(waiter).pipe(Effect.timeoutOrElse({
254
347
  duration: limits.command,
255
- orElse: () => Effect.fail(new ReactorError("Timeout", "H3 did not observe the command's exact returned envelope")),
348
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "H3 did not observe the command's exact returned envelope")),
256
349
  }), Effect.ensuring(Effect.sync(() => {
257
350
  observedWaiters.delete(source);
258
351
  })));
259
352
  });
260
353
  const call = (operation, args, needsFacts = true) => Effect.gen(function* () {
261
354
  yield* active(operation, needsFacts);
262
- const source = yield* session.command(operation, args, undefined, limits.command);
355
+ const source = yield* session.command(operation, args, { replyTimeout: limits.command });
263
356
  const message = yield* awaitObservation(source).pipe(Effect.mapError((error) => uncertain(operation, source, "H3 reply observation failed", error)));
264
357
  if (message?.type === "command_error" && message.data.command === operation)
265
- return yield* Effect.fail(rejected(operation, source));
358
+ return yield* rejected(operation, source, message.data.reason);
266
359
  return { source, message };
267
360
  });
268
361
  const named = (operation, args, needsFacts = true) => {
269
362
  const expected = Commands[operation].reply;
270
363
  return call(operation, args, needsFacts).pipe(Effect.flatMap(({ source, message }) => message?.type === expected
271
- ? Effect.succeed(Object.freeze({ value: message.data, source }))
364
+ ? // The broadcasts a reply implies follow it: the command settles once
365
+ // they are observed, so its caller reads its own effects. The state
366
+ // and queue reads are themselves that barrier and do not wait.
367
+ (needsFacts ? settled : Effect.void).pipe(Effect.as(Object.freeze({ value: message.data, source })))
272
368
  : Effect.fail(uncertain(operation, source, `H3 ${operation} did not return ${expected}`))));
273
369
  };
274
370
  const checked = (operation, value) => pure(value).pipe(Effect.mapError((error) => localFailure(operation, error)));
@@ -276,7 +372,7 @@ const build = (session, options) => Effect.gen(function* () {
276
372
  const getQueue = named("get_queue", {}, false);
277
373
  const refresh = getState.pipe(Effect.andThen(getQueue), Effect.flatMap((reply) => state.snapshot()._tag === "Ready"
278
374
  ? Effect.void
279
- : Effect.fail(new CommandFailure(new ReactorError("InvalidState", "H3 full snapshots changed during refresh"), {
375
+ : Effect.fail(CommandFailure.from(ReactorError.fromCode("InvalidState", "H3 full snapshots changed during refresh"), {
280
376
  operation: "H3 refresh",
281
377
  outcome: "replied",
282
378
  requestId: reply.source.requestId,
@@ -285,13 +381,13 @@ const build = (session, options) => Effect.gen(function* () {
285
381
  const control = (operation, args) => call(operation, args).pipe(Effect.flatMap(({ source, message }) => source.kind === "ack"
286
382
  ? Effect.succeed(Object.freeze({ _tag: "Acknowledged", source }))
287
383
  : message !== undefined && message.type !== "unknown"
288
- ? Effect.succeed(Object.freeze({ _tag: "Reply", message, source }))
384
+ ? settled.pipe(Effect.as(Object.freeze({ _tag: "Reply", message, source })))
289
385
  : Effect.fail(uncertain(operation, source, "Unexpected H3 control response"))));
290
386
  const stage = (request, metadata) => Effect.gen(function* () {
291
387
  yield* active("enqueue", true);
292
388
  const before = state.snapshot();
293
389
  if (before._tag !== "Ready")
294
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("InvalidState", "H3 is not ready")));
390
+ return yield* localFailure("enqueue", ReactorError.fromCode("InvalidState", "H3 is not ready"));
295
391
  if (request.seconds !== undefined)
296
392
  yield* checked("enqueue", () => seconds(request.seconds, {
297
393
  min: before.state.clip_seconds_min,
@@ -306,20 +402,22 @@ const build = (session, options) => Effect.gen(function* () {
306
402
  }
307
403
  const digest = yield* crypto
308
404
  .digest("SHA-256", material.bytes)
309
- .pipe(Effect.mapError(() => localFailure("enqueue", new ReactorError("InvalidState", "Could not hash H3 reference"))));
405
+ .pipe(Effect.mapError(() => localFailure("enqueue", ReactorError.fromCode("InvalidState", "Could not hash H3 reference"))));
310
406
  const key = `${before.transportGeneration}:${hex(digest)}`;
311
407
  const file = yield* uploadGate.withPermit(Effect.gen(function* () {
312
408
  const cached = uploads.get(key);
313
409
  if (cached !== undefined)
314
410
  return cached;
315
411
  const uploaded = yield* session
316
- .upload(`h3-${hex(digest)}`, reference.mimeType, material.bytes, limits.command)
412
+ .upload(`h3-${hex(digest)}`, reference.mimeType, material.bytes, {
413
+ uploadTimeout: limits.upload,
414
+ })
317
415
  .pipe(Effect.mapError((error) => localFailure("enqueue", error)));
318
416
  const file = yield* checked("enqueue", () => checkedUpload(uploaded.file));
319
417
  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")));
418
+ return yield* localFailure("enqueue", ReactorError.fromCode("Protocol", "H3 upload returned different file facts"));
321
419
  if (state.snapshot().transportGeneration !== before.transportGeneration)
322
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("Disconnected", "H3 reference upload crossed a generation")));
420
+ return yield* localFailure("enqueue", ReactorError.fromCode("Disconnected", "H3 reference upload crossed a generation"));
323
421
  if (uploads.size >= limits.cache) {
324
422
  const first = uploads.keys().next();
325
423
  if (!first.done)
@@ -335,7 +433,7 @@ const build = (session, options) => Effect.gen(function* () {
335
433
  });
336
434
  const nextId = () => pure(() => {
337
435
  if (counter === 0xffffffffffffffffn)
338
- throw new ReactorError("Overflow", "H3 submission identity space exhausted", {
436
+ throw ReactorError.fromCode("Overflow", "H3 submission identity space exhausted", {
339
437
  outcome: "not-submitted",
340
438
  });
341
439
  return `${namespace}:${++counter}`;
@@ -343,27 +441,29 @@ const build = (session, options) => Effect.gen(function* () {
343
441
  const prepared = (id, input, hooks) => Submission.make({
344
442
  id,
345
443
  prepare: Effect.gen(function* () {
346
- const { request, metadata } = yield* input.pipe(Effect.mapError((error) => localFailure("enqueue", error)));
444
+ const { request, metadata } = yield* input.pipe(Effect.mapError((error) => preparationFailure("enqueue", error)));
347
445
  const staged = yield* stage(request, metadata);
348
- return {
349
- ...staged,
350
- entry: {
351
- id,
352
- metadata,
353
- prompt: request.prompt,
354
- generation: staged.generation,
355
- deferred: Deferred.makeUnsafe(),
356
- },
446
+ const entry = {
447
+ id,
448
+ metadata,
449
+ prompt: request.prompt,
450
+ generation: staged.generation,
451
+ deferred: Deferred.makeUnsafe(),
452
+ awaiting: true,
453
+ held: undefined,
357
454
  };
455
+ return { ...staged, entry };
358
456
  }),
359
457
  commit: ({ entry }) => Effect.gen(function* () {
360
458
  yield* active("enqueue", true);
361
459
  const live = yield* session.ready.pipe(Effect.mapError((error) => localFailure("enqueue", error)));
362
460
  if (entry.generation !== state.snapshot().transportGeneration ||
363
461
  live.generation !== entry.generation)
364
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("Disconnected", "H3 prepared input crossed a transport generation")));
462
+ return yield* localFailure("enqueue", ReactorError.fromCode("Disconnected", "H3 prepared input crossed a transport generation"));
365
463
  if (pending.size >= limits.pending)
366
- return yield* Effect.fail(localFailure("enqueue", new ReactorError("Overflow", "H3 pending acceptance bound reached")));
464
+ return yield* localFailure("enqueue", ReactorError.fromCode("Overflow", "H3 pending acceptance bound reached"));
465
+ // The operation's slot is taken before anything is sent.
466
+ yield* pure(() => operations.reserve(entry)).pipe(Effect.mapError((error) => localFailure("enqueue", error)));
367
467
  // Acceptance registration is inside the same commit boundary as the
368
468
  // orchestration hook and precedes Session.command's wire correlation.
369
469
  pending.set(id, entry);
@@ -371,16 +471,17 @@ const build = (session, options) => Effect.gen(function* () {
371
471
  yield* hooks.commit(id).pipe(Effect.onExit((exit) => Exit.isFailure(exit)
372
472
  ? Effect.sync(() => {
373
473
  pending.delete(id);
474
+ operations.abandon(id);
374
475
  })
375
476
  : Effect.void));
376
477
  }),
377
478
  execute: ({ args, entry }) => {
378
479
  const execution = Effect.gen(function* () {
379
- const sent = yield* Effect.result(session.command("enqueue", args, undefined, limits.command));
480
+ const sent = yield* Effect.result(session.command("enqueue", args, { replyTimeout: limits.command }));
380
481
  let original;
381
482
  if (Result.isFailure(sent)) {
382
483
  if (sent.failure.context.outcome !== "unknown")
383
- return yield* Effect.fail(sent.failure);
484
+ return yield* sent.failure;
384
485
  original = sent.failure;
385
486
  }
386
487
  else {
@@ -389,29 +490,58 @@ const build = (session, options) => Effect.gen(function* () {
389
490
  if (Result.isSuccess(observed) &&
390
491
  observed.success?.type === "command_error" &&
391
492
  observed.success.data.command === "enqueue")
392
- return yield* Effect.fail(rejected("enqueue", source));
493
+ return yield* rejected("enqueue", source, observed.success.data.reason);
393
494
  original = uncertain("enqueue", source, "H3 enqueue has no proven clip acceptance", Result.isFailure(observed) ? observed.failure : undefined);
394
495
  }
395
- return yield* Deferred.await(entry.deferred).pipe(Effect.timeoutOrElse({
496
+ // The reply was observed or is lost: evidence by metadata that came
497
+ // while it was awaited now decides, and later evidence at once.
498
+ entry.awaiting = false;
499
+ decideHeld(entry);
500
+ const acceptance = yield* Deferred.await(entry.deferred).pipe(Effect.timeoutOrElse({
396
501
  duration: limits.reconcile,
397
502
  orElse: () => Effect.fail(original),
398
- }), Effect.mapError(() => original));
399
- }).pipe(Effect.ensuring(Effect.sync(() => {
503
+ }), Effect.mapError(() => original), Effect.withSpan("reactor.h3.reconcile", {}, { captureStackTrace: false }));
504
+ // Like every command, an enqueue settles once the snapshots its reply
505
+ // implies are observed, so its caller finds the clip in the queue.
506
+ yield* settled;
507
+ return acceptance;
508
+ }).pipe(Effect.onExit((exit) => Effect.sync(() => {
509
+ // However the enqueue ends, interrupted as the provider closes or
510
+ // otherwise, the evidence it saw still decides; only a definite
511
+ // failure discards it.
512
+ const error = Exit.findError(exit);
513
+ if (!(error._tag === "Success" && error.success.context.outcome !== "unknown"))
514
+ decideHeld(entry);
400
515
  pending.delete(id);
401
- })));
516
+ })), Effect.onExit((exit) => Effect.sync(() => {
517
+ operations.settle(id, exit);
518
+ })),
519
+ // The submission's own execution fiber carries the span, so it ends with
520
+ // the enqueue's outcome even when the caller stopped waiting.
521
+ Effect.onExit((exit) => {
522
+ const error = Exit.findError(exit);
523
+ return Effect.annotateCurrentSpan(Exit.isSuccess(exit)
524
+ ? { "reactor.command.outcome": "replied" }
525
+ : error._tag === "Success"
526
+ ? {
527
+ "reactor.command.outcome": error.success.context.outcome,
528
+ "error.type": error.success.reason._tag,
529
+ }
530
+ : {});
531
+ }), Effect.withSpan("reactor.h3.enqueue", { kind: "client", attributes: { "reactor.h3.submission.id": id } }, { captureStackTrace: false }));
402
532
  return hooks.result === undefined
403
533
  ? execution
404
534
  : Effect.result(execution).pipe(Effect.flatMap((result) => Effect.gen(function* () {
405
535
  const hook = yield* Effect.exit(Effect.suspend(() => hooks.result(id, result)).pipe(Effect.interruptible, Effect.timeoutOrElse({
406
536
  duration: limits.hook,
407
- orElse: () => Effect.fail(new ReactorError("Timeout", "H3 result hook exceeded its deadline")),
537
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "H3 result hook exceeded its deadline")),
408
538
  })));
409
539
  // Preserve the entire secondary Cause for deliberate inspection,
410
540
  // including defects; never substitute it for primary remote evidence.
411
541
  if (Exit.isFailure(hook))
412
542
  emit({
413
543
  _tag: "Diagnostic",
414
- error: new ReactorError("InvalidState", "H3 result hook failed", {
544
+ error: ReactorError.fromCode("InvalidState", "H3 result hook failed", {
415
545
  operation: "H3 result hook",
416
546
  detail: hook.cause,
417
547
  }),
@@ -421,7 +551,7 @@ const build = (session, options) => Effect.gen(function* () {
421
551
  : Effect.fail(result.failure);
422
552
  })));
423
553
  },
424
- }).pipe(Scope.provide(scope));
554
+ }).pipe(Scope.provide(executions));
425
555
  const prepare = (input, hooks = {}) => Effect.gen(function* () {
426
556
  yield* active("enqueue", true);
427
557
  const request = yield* pure(() => captureRequest(input, limits.prompt));
@@ -432,9 +562,9 @@ const build = (session, options) => Effect.gen(function* () {
432
562
  const prepareFrom = (preparation, hooks = {}) => Effect.gen(function* () {
433
563
  yield* active("enqueue", true);
434
564
  if (!Effect.isEffect(preparation))
435
- return yield* Effect.fail(new ReactorError("InvalidInput", "H3 preparation must be an Effect", {
565
+ return yield* ReactorError.fromCode("InvalidInput", "H3 preparation must be an Effect", {
436
566
  outcome: "not-submitted",
437
- }));
567
+ });
438
568
  const id = yield* nextId();
439
569
  return yield* prepared(id, preparation.pipe(Effect.flatMap((input) => pure(() => {
440
570
  const request = captureRequest(input, limits.prompt);
@@ -443,14 +573,15 @@ const build = (session, options) => Effect.gen(function* () {
443
573
  });
444
574
  const contract = yield* session.schema.pipe(Effect.flatMap(({ openapi }) => pure(() => validateDeployment(openapi))), Effect.timeoutOrElse({
445
575
  duration: limits.setup,
446
- orElse: () => Effect.fail(new ReactorError("Timeout", "H3 schema read timed out")),
576
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "H3 schema read timed out")),
447
577
  }));
448
578
  yield* refresh.pipe(Effect.timeoutOrElse({
449
579
  duration: limits.setup,
450
- orElse: () => Effect.fail(new ReactorError("Timeout", "H3 initial state and queue reads timed out")),
580
+ orElse: () => Effect.fail(ReactorError.fromCode("Timeout", "H3 initial state and queue reads timed out")),
451
581
  }));
452
582
  if (state.snapshot()._tag !== "Ready")
453
- return yield* Effect.fail(fatalError ?? new ReactorError("InvalidState", "H3 initial observations are unavailable"));
583
+ return yield* (fatalError ??
584
+ ReactorError.fromCode("InvalidState", "H3 initial observations are unavailable"));
454
585
  const provider = {
455
586
  sessionId: session.id,
456
587
  contract,
@@ -458,44 +589,47 @@ const build = (session, options) => Effect.gen(function* () {
458
589
  observe: (bounds) => Effect.gen(function* () {
459
590
  const stream = yield* events.subscribe(bounds);
460
591
  const initial = state.snapshot();
461
- return { initial, revision: initial.revision, events: stream };
592
+ // The reducer may apply a queued event between the subscription and
593
+ // this read; the snapshot then covers it, so it is not repeated.
594
+ return {
595
+ initial,
596
+ revision: initial.revision,
597
+ events: Stream.filter(stream, (event) => !covered(event, initial.revision)),
598
+ };
462
599
  }),
463
600
  events: (bounds) => events.stream(bounds),
464
601
  failure: Deferred.await(fatal),
465
602
  acceptances: Effect.sync(() => Object.freeze([...acceptances.values()])),
466
603
  acceptance: (id) => Effect.sync(() => acceptances.get(id)),
604
+ operation: (submission) => operations.attach(submission.id),
467
605
  prepare,
468
606
  prepareFrom,
469
- enqueue: (request) => prepare(request).pipe(Effect.mapError((error) => localFailure("enqueue", error)), Effect.flatMap((submission) => submission.submit)),
607
+ enqueue: (request) => prepare(request).pipe(Effect.mapError((error) => ReactorError.is(error) ? localFailure("enqueue", error) : error), Effect.flatMap((submission) => submission.submit)),
470
608
  getState,
471
609
  getQueue,
472
610
  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")))),
611
+ 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
612
  move: (id, position) => checked("move", () => ({
477
613
  clip_id: clipId(id),
478
614
  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")))),
615
+ })).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
616
  play: (id) => checked("play", () => ({ clip_id: id === undefined ? "" : clipId(id) })).pipe(Effect.flatMap((args) => control("play", args))),
483
617
  stop: control("stop", {}),
484
618
  setSeed: (value) => checked("set_seed", () => nonnegative(value, "seed")).pipe(Effect.flatMap((seed) => named("set_seed", { seed }))),
485
619
  setClipSeconds: (value) => checked("set_clip_seconds", () => seconds(value)).pipe(Effect.flatMap((seconds) => named("set_clip_seconds", { seconds }))),
486
620
  setCanvas: (aspect) => checked("set_canvas", () => {
487
621
  if (!Object.hasOwn(canvases, aspect))
488
- throw new ReactorError("InvalidInput", "Unsupported H3 canvas aspect");
622
+ throw ReactorError.fromCode("InvalidInput", "Unsupported H3 canvas aspect");
489
623
  return { aspect };
490
624
  }).pipe(Effect.flatMap((args) => named("set_canvas", args))),
491
625
  setAutoplay: (enabled) => checked("set_autoplay", () => {
492
626
  if (typeof enabled !== "boolean")
493
- throw new ReactorError("InvalidInput", "Autoplay must be boolean");
627
+ throw ReactorError.fromCode("InvalidInput", "Autoplay must be boolean");
494
628
  return { enabled };
495
629
  }).pipe(Effect.flatMap((args) => named("set_autoplay", args))),
496
630
  setFlushOnClipEnd: (enabled) => checked("set_flush_on_clip_end", () => {
497
631
  if (typeof enabled !== "boolean")
498
- throw new ReactorError("InvalidInput", "Flush setting must be boolean");
632
+ throw ReactorError.fromCode("InvalidInput", "Flush setting must be boolean");
499
633
  return { enabled };
500
634
  }).pipe(Effect.flatMap((args) => named("set_flush_on_clip_end", args))),
501
635
  reset: named("reset", {}),