reactor-effect-client 0.7.0 → 0.8.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 (514) hide show
  1. package/NOTICE +7 -2
  2. package/README.md +103 -141
  3. package/dist/Coordinator.d.ts +382 -0
  4. package/dist/Coordinator.d.ts.map +1 -0
  5. package/dist/Coordinator.js +732 -0
  6. package/dist/Coordinator.js.map +1 -0
  7. package/dist/H3.d.ts +159 -0
  8. package/dist/H3.d.ts.map +1 -0
  9. package/dist/H3.js +21 -0
  10. package/dist/H3.js.map +1 -0
  11. package/dist/H3Source.d.ts +93 -0
  12. package/dist/H3Source.d.ts.map +1 -0
  13. package/dist/H3Source.js +401 -0
  14. package/dist/H3Source.js.map +1 -0
  15. package/dist/LocalSource.d.ts +68 -0
  16. package/dist/LocalSource.d.ts.map +1 -0
  17. package/dist/LocalSource.js +354 -0
  18. package/dist/LocalSource.js.map +1 -0
  19. package/dist/Media.d.ts +106 -0
  20. package/dist/Media.d.ts.map +1 -0
  21. package/dist/Media.js +16 -0
  22. package/dist/Media.js.map +1 -0
  23. package/dist/Peer.d.ts +134 -0
  24. package/dist/Peer.d.ts.map +1 -0
  25. package/dist/Peer.js +57 -0
  26. package/dist/Peer.js.map +1 -0
  27. package/dist/Playout.d.ts +724 -0
  28. package/dist/Playout.d.ts.map +1 -0
  29. package/dist/Playout.js +28 -0
  30. package/dist/Playout.js.map +1 -0
  31. package/dist/Reactor.d.ts +135 -0
  32. package/dist/Reactor.d.ts.map +1 -0
  33. package/dist/Reactor.js +158 -0
  34. package/dist/Reactor.js.map +1 -0
  35. package/dist/ReactorError.d.ts +331 -0
  36. package/dist/ReactorError.d.ts.map +1 -0
  37. package/dist/ReactorError.js +316 -0
  38. package/dist/ReactorError.js.map +1 -0
  39. package/dist/ReactorTest.d.ts +383 -0
  40. package/dist/ReactorTest.d.ts.map +1 -0
  41. package/dist/ReactorTest.js +317 -0
  42. package/dist/ReactorTest.js.map +1 -0
  43. package/dist/Session.d.ts +314 -0
  44. package/dist/Session.d.ts.map +1 -0
  45. package/dist/Session.js +51 -0
  46. package/dist/Session.js.map +1 -0
  47. package/dist/index.d.ts +12 -22
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +12 -11
  50. package/dist/index.js.map +1 -1
  51. package/dist/internal/correlator.d.ts +46 -0
  52. package/dist/internal/correlator.d.ts.map +1 -0
  53. package/dist/internal/correlator.js +150 -0
  54. package/dist/internal/correlator.js.map +1 -0
  55. package/dist/internal/deadline.d.ts +24 -0
  56. package/dist/internal/deadline.d.ts.map +1 -0
  57. package/dist/internal/deadline.js +44 -0
  58. package/dist/internal/deadline.js.map +1 -0
  59. package/dist/{h3/_internal/contracts.d.ts → internal/h3/commands.d.ts} +39 -33
  60. package/dist/internal/h3/commands.d.ts.map +1 -0
  61. package/dist/internal/h3/commands.js +128 -0
  62. package/dist/internal/h3/commands.js.map +1 -0
  63. package/dist/{h3 → internal/h3}/messages.d.ts +16 -11
  64. package/dist/internal/h3/messages.d.ts.map +1 -0
  65. package/dist/{h3 → internal/h3}/messages.js +19 -31
  66. package/dist/internal/h3/messages.js.map +1 -0
  67. package/dist/internal/h3/operations.d.ts +145 -0
  68. package/dist/internal/h3/operations.d.ts.map +1 -0
  69. package/dist/internal/h3/operations.js +284 -0
  70. package/dist/internal/h3/operations.js.map +1 -0
  71. package/dist/{h3 → internal/h3}/profile.d.ts +63 -65
  72. package/dist/internal/h3/profile.d.ts.map +1 -0
  73. package/dist/{h3 → internal/h3}/profile.js +39 -39
  74. package/dist/internal/h3/profile.js.map +1 -0
  75. package/dist/internal/h3/provider.d.ts +14 -0
  76. package/dist/internal/h3/provider.d.ts.map +1 -0
  77. package/dist/internal/h3/provider.js +672 -0
  78. package/dist/internal/h3/provider.js.map +1 -0
  79. package/dist/internal/h3/references.d.ts +68 -0
  80. package/dist/internal/h3/references.d.ts.map +1 -0
  81. package/dist/internal/h3/references.js +339 -0
  82. package/dist/internal/h3/references.js.map +1 -0
  83. package/dist/internal/h3/request.d.ts +56 -0
  84. package/dist/internal/h3/request.d.ts.map +1 -0
  85. package/dist/internal/h3/request.js +70 -0
  86. package/dist/internal/h3/request.js.map +1 -0
  87. package/dist/internal/h3/state.d.ts +120 -0
  88. package/dist/internal/h3/state.d.ts.map +1 -0
  89. package/dist/internal/h3/state.js +270 -0
  90. package/dist/internal/h3/state.js.map +1 -0
  91. package/dist/{Submission.d.ts → internal/h3/submission.d.ts} +1 -3
  92. package/dist/internal/h3/submission.d.ts.map +1 -0
  93. package/dist/{Submission.js → internal/h3/submission.js} +1 -9
  94. package/dist/internal/h3/submission.js.map +1 -0
  95. package/dist/internal/hub.d.ts +32 -0
  96. package/dist/internal/hub.d.ts.map +1 -0
  97. package/dist/internal/hub.js +92 -0
  98. package/dist/internal/hub.js.map +1 -0
  99. package/dist/internal/playout/errors.d.ts +51 -0
  100. package/dist/internal/playout/errors.d.ts.map +1 -0
  101. package/dist/internal/playout/errors.js +40 -0
  102. package/dist/internal/playout/errors.js.map +1 -0
  103. package/dist/internal/playout/policy.d.ts +559 -0
  104. package/dist/internal/playout/policy.d.ts.map +1 -0
  105. package/dist/internal/playout/policy.js +2338 -0
  106. package/dist/internal/playout/policy.js.map +1 -0
  107. package/dist/internal/playout/runtime.d.ts +28 -0
  108. package/dist/internal/playout/runtime.d.ts.map +1 -0
  109. package/dist/internal/playout/runtime.js +766 -0
  110. package/dist/internal/playout/runtime.js.map +1 -0
  111. package/dist/internal/playout/tag.d.ts +14 -0
  112. package/dist/internal/playout/tag.d.ts.map +1 -0
  113. package/dist/internal/playout/tag.js +43 -0
  114. package/dist/internal/playout/tag.js.map +1 -0
  115. package/dist/internal/proto/reactor_wire/v1/common_pb.d.ts +55 -0
  116. package/dist/internal/proto/reactor_wire/v1/common_pb.d.ts.map +1 -0
  117. package/dist/internal/proto/reactor_wire/v1/common_pb.js +42 -0
  118. package/dist/internal/proto/reactor_wire/v1/common_pb.js.map +1 -0
  119. package/dist/internal/proto/reactor_wire/v1/control_pb.d.ts +160 -0
  120. package/dist/internal/proto/reactor_wire/v1/control_pb.d.ts.map +1 -0
  121. package/dist/internal/proto/reactor_wire/v1/control_pb.js +21 -0
  122. package/dist/internal/proto/reactor_wire/v1/control_pb.js.map +1 -0
  123. package/dist/internal/proto/reactor_wire/v1/data_pb.d.ts +89 -0
  124. package/dist/internal/proto/reactor_wire/v1/data_pb.d.ts.map +1 -0
  125. package/dist/internal/proto/reactor_wire/v1/data_pb.js +20 -0
  126. package/dist/internal/proto/reactor_wire/v1/data_pb.js.map +1 -0
  127. package/dist/internal/proto/reactor_wire/v1/model_pb.d.ts +85 -0
  128. package/dist/internal/proto/reactor_wire/v1/model_pb.d.ts.map +1 -0
  129. package/dist/internal/proto/reactor_wire/v1/model_pb.js +24 -0
  130. package/dist/internal/proto/reactor_wire/v1/model_pb.js.map +1 -0
  131. package/dist/internal/proto/reactor_wire/v1/platform_pb.d.ts +190 -0
  132. package/dist/internal/proto/reactor_wire/v1/platform_pb.d.ts.map +1 -0
  133. package/dist/internal/proto/reactor_wire/v1/platform_pb.js +54 -0
  134. package/dist/internal/proto/reactor_wire/v1/platform_pb.js.map +1 -0
  135. package/dist/internal/proto/reactor_wire/v1/track_pb.d.ts +88 -0
  136. package/dist/internal/proto/reactor_wire/v1/track_pb.d.ts.map +1 -0
  137. package/dist/internal/proto/reactor_wire/v1/track_pb.js +33 -0
  138. package/dist/internal/proto/reactor_wire/v1/track_pb.js.map +1 -0
  139. package/dist/internal/queue.d.ts +4 -0
  140. package/dist/internal/queue.d.ts.map +1 -0
  141. package/dist/{_internal → internal}/queue.js +0 -9
  142. package/dist/internal/queue.js.map +1 -0
  143. package/dist/internal/reactorTest/coordinator.d.ts +5 -0
  144. package/dist/internal/reactorTest/coordinator.d.ts.map +1 -0
  145. package/dist/internal/reactorTest/coordinator.js +178 -0
  146. package/dist/internal/reactorTest/coordinator.js.map +1 -0
  147. package/dist/internal/reactorTest/faults.d.ts +172 -0
  148. package/dist/internal/reactorTest/faults.d.ts.map +1 -0
  149. package/dist/internal/reactorTest/faults.js +23 -0
  150. package/dist/internal/reactorTest/faults.js.map +1 -0
  151. package/dist/internal/reactorTest/h3.d.ts +431 -0
  152. package/dist/internal/reactorTest/h3.d.ts.map +1 -0
  153. package/dist/internal/reactorTest/h3.js +625 -0
  154. package/dist/internal/reactorTest/h3.js.map +1 -0
  155. package/dist/internal/reactorTest/media.d.ts +22 -0
  156. package/dist/internal/reactorTest/media.d.ts.map +1 -0
  157. package/dist/internal/reactorTest/media.js +34 -0
  158. package/dist/internal/reactorTest/media.js.map +1 -0
  159. package/dist/internal/reactorTest/peer.d.ts +318 -0
  160. package/dist/internal/reactorTest/peer.d.ts.map +1 -0
  161. package/dist/internal/reactorTest/peer.js +206 -0
  162. package/dist/internal/reactorTest/peer.js.map +1 -0
  163. package/dist/internal/reactorTest/playout.d.ts +34 -0
  164. package/dist/internal/reactorTest/playout.d.ts.map +1 -0
  165. package/dist/internal/reactorTest/playout.js +340 -0
  166. package/dist/internal/reactorTest/playout.js.map +1 -0
  167. package/dist/internal/reactorTest/references.d.ts +25 -0
  168. package/dist/internal/reactorTest/references.d.ts.map +1 -0
  169. package/dist/{testing/Png.js → internal/reactorTest/references.js} +50 -12
  170. package/dist/internal/reactorTest/references.js.map +1 -0
  171. package/dist/internal/reactorTest/sessions.d.ts +364 -0
  172. package/dist/internal/reactorTest/sessions.d.ts.map +1 -0
  173. package/dist/internal/reactorTest/sessions.js +592 -0
  174. package/dist/internal/reactorTest/sessions.js.map +1 -0
  175. package/dist/internal/reactorTest/timing.d.ts +13 -0
  176. package/dist/internal/reactorTest/timing.d.ts.map +1 -0
  177. package/dist/internal/reactorTest/timing.js +20 -0
  178. package/dist/internal/reactorTest/timing.js.map +1 -0
  179. package/dist/internal/recording.d.ts +52 -0
  180. package/dist/internal/recording.d.ts.map +1 -0
  181. package/dist/internal/recording.js +141 -0
  182. package/dist/internal/recording.js.map +1 -0
  183. package/dist/internal/session/connect.d.ts +34 -0
  184. package/dist/internal/session/connect.d.ts.map +1 -0
  185. package/dist/internal/session/connect.js +502 -0
  186. package/dist/internal/session/connect.js.map +1 -0
  187. package/dist/internal/session/generation.d.ts +35 -0
  188. package/dist/internal/session/generation.d.ts.map +1 -0
  189. package/dist/internal/session/generation.js +185 -0
  190. package/dist/internal/session/generation.js.map +1 -0
  191. package/dist/internal/session/ice.d.ts +23 -0
  192. package/dist/internal/session/ice.d.ts.map +1 -0
  193. package/dist/internal/session/ice.js +57 -0
  194. package/dist/internal/session/ice.js.map +1 -0
  195. package/dist/internal/session/inbound.d.ts +50 -0
  196. package/dist/internal/session/inbound.d.ts.map +1 -0
  197. package/dist/internal/session/inbound.js +202 -0
  198. package/dist/internal/session/inbound.js.map +1 -0
  199. package/dist/internal/session/model.d.ts +163 -0
  200. package/dist/internal/session/model.d.ts.map +1 -0
  201. package/dist/internal/session/model.js +78 -0
  202. package/dist/internal/session/model.js.map +1 -0
  203. package/dist/internal/session/requests.d.ts +71 -0
  204. package/dist/internal/session/requests.d.ts.map +1 -0
  205. package/dist/internal/session/requests.js +240 -0
  206. package/dist/internal/session/requests.js.map +1 -0
  207. package/dist/internal/session/tracks.d.ts +21 -0
  208. package/dist/internal/session/tracks.d.ts.map +1 -0
  209. package/dist/internal/session/tracks.js +166 -0
  210. package/dist/internal/session/tracks.js.map +1 -0
  211. package/dist/internal/session/upload.d.ts +21 -0
  212. package/dist/internal/session/upload.d.ts.map +1 -0
  213. package/dist/internal/session/upload.js +68 -0
  214. package/dist/internal/session/upload.js.map +1 -0
  215. package/dist/internal/session.d.ts +66 -0
  216. package/dist/internal/session.d.ts.map +1 -0
  217. package/dist/internal/session.js +280 -0
  218. package/dist/internal/session.js.map +1 -0
  219. package/dist/{stats.d.ts → internal/stats.d.ts} +30 -16
  220. package/dist/internal/stats.d.ts.map +1 -0
  221. package/dist/internal/stats.js +201 -0
  222. package/dist/internal/stats.js.map +1 -0
  223. package/dist/internal/token.d.ts +22 -0
  224. package/dist/internal/token.d.ts.map +1 -0
  225. package/dist/internal/token.js +65 -0
  226. package/dist/internal/token.js.map +1 -0
  227. package/dist/internal/wire.d.ts +37 -0
  228. package/dist/internal/wire.d.ts.map +1 -0
  229. package/dist/internal/wire.js +49 -0
  230. package/dist/internal/wire.js.map +1 -0
  231. package/notices/bufbuild-protobuf-LICENSE +201 -0
  232. package/package.json +17 -38
  233. package/dist/FetchHttp.d.ts +0 -9
  234. package/dist/FetchHttp.d.ts.map +0 -1
  235. package/dist/FetchHttp.js +0 -63
  236. package/dist/FetchHttp.js.map +0 -1
  237. package/dist/PeerFactory.d.ts +0 -26
  238. package/dist/PeerFactory.d.ts.map +0 -1
  239. package/dist/PeerFactory.js +0 -4
  240. package/dist/PeerFactory.js.map +0 -1
  241. package/dist/PeerTypes.d.ts +0 -64
  242. package/dist/PeerTypes.d.ts.map +0 -1
  243. package/dist/PeerTypes.js +0 -2
  244. package/dist/PeerTypes.js.map +0 -1
  245. package/dist/Sequence.d.ts +0 -85
  246. package/dist/Sequence.d.ts.map +0 -1
  247. package/dist/Sequence.js +0 -210
  248. package/dist/Sequence.js.map +0 -1
  249. package/dist/SessionTypes.d.ts +0 -312
  250. package/dist/SessionTypes.d.ts.map +0 -1
  251. package/dist/SessionTypes.js +0 -19
  252. package/dist/SessionTypes.js.map +0 -1
  253. package/dist/Submission.d.ts.map +0 -1
  254. package/dist/Submission.js.map +0 -1
  255. package/dist/_internal/queue.d.ts +0 -6
  256. package/dist/_internal/queue.d.ts.map +0 -1
  257. package/dist/_internal/queue.js.map +0 -1
  258. package/dist/bytes.d.ts +0 -6
  259. package/dist/bytes.d.ts.map +0 -1
  260. package/dist/bytes.js +0 -32
  261. package/dist/bytes.js.map +0 -1
  262. package/dist/contract.d.ts +0 -149
  263. package/dist/contract.d.ts.map +0 -1
  264. package/dist/contract.js +0 -119
  265. package/dist/contract.js.map +0 -1
  266. package/dist/coordinator/_internal/client.d.ts +0 -215
  267. package/dist/coordinator/_internal/client.d.ts.map +0 -1
  268. package/dist/coordinator/_internal/client.js +0 -563
  269. package/dist/coordinator/_internal/client.js.map +0 -1
  270. package/dist/coordinator/_internal/recording.d.ts +0 -31
  271. package/dist/coordinator/_internal/recording.d.ts.map +0 -1
  272. package/dist/coordinator/_internal/recording.js +0 -123
  273. package/dist/coordinator/_internal/recording.js.map +0 -1
  274. package/dist/coordinator/_internal/response.d.ts +0 -15
  275. package/dist/coordinator/_internal/response.d.ts.map +0 -1
  276. package/dist/coordinator/_internal/response.js +0 -55
  277. package/dist/coordinator/_internal/response.js.map +0 -1
  278. package/dist/coordinator/_internal/schemas.d.ts +0 -73
  279. package/dist/coordinator/_internal/schemas.d.ts.map +0 -1
  280. package/dist/coordinator/_internal/schemas.js +0 -200
  281. package/dist/coordinator/_internal/schemas.js.map +0 -1
  282. package/dist/coordinator/index.d.ts +0 -40
  283. package/dist/coordinator/index.d.ts.map +0 -1
  284. package/dist/coordinator/index.js +0 -21
  285. package/dist/coordinator/index.js.map +0 -1
  286. package/dist/correlation.d.ts +0 -47
  287. package/dist/correlation.d.ts.map +0 -1
  288. package/dist/correlation.js +0 -131
  289. package/dist/correlation.js.map +0 -1
  290. package/dist/duration.d.ts +0 -27
  291. package/dist/duration.d.ts.map +0 -1
  292. package/dist/duration.js +0 -47
  293. package/dist/duration.js.map +0 -1
  294. package/dist/errors.d.ts +0 -1303
  295. package/dist/errors.d.ts.map +0 -1
  296. package/dist/errors.js +0 -745
  297. package/dist/errors.js.map +0 -1
  298. package/dist/h3/_internal/client.d.ts +0 -9
  299. package/dist/h3/_internal/client.d.ts.map +0 -1
  300. package/dist/h3/_internal/client.js +0 -652
  301. package/dist/h3/_internal/client.js.map +0 -1
  302. package/dist/h3/_internal/contracts.d.ts.map +0 -1
  303. package/dist/h3/_internal/contracts.js +0 -123
  304. package/dist/h3/_internal/contracts.js.map +0 -1
  305. package/dist/h3/_internal/deployment.d.ts +0 -8
  306. package/dist/h3/_internal/deployment.d.ts.map +0 -1
  307. package/dist/h3/_internal/deployment.js +0 -149
  308. package/dist/h3/_internal/deployment.js.map +0 -1
  309. package/dist/h3/_internal/evidence.d.ts +0 -17
  310. package/dist/h3/_internal/evidence.d.ts.map +0 -1
  311. package/dist/h3/_internal/evidence.js +0 -48
  312. package/dist/h3/_internal/evidence.js.map +0 -1
  313. package/dist/h3/_internal/operations.d.ts +0 -58
  314. package/dist/h3/_internal/operations.d.ts.map +0 -1
  315. package/dist/h3/_internal/operations.js +0 -268
  316. package/dist/h3/_internal/operations.js.map +0 -1
  317. package/dist/h3/_internal/references.d.ts +0 -24
  318. package/dist/h3/_internal/references.d.ts.map +0 -1
  319. package/dist/h3/_internal/references.js +0 -309
  320. package/dist/h3/_internal/references.js.map +0 -1
  321. package/dist/h3/_internal/request.d.ts +0 -21
  322. package/dist/h3/_internal/request.d.ts.map +0 -1
  323. package/dist/h3/_internal/request.js +0 -97
  324. package/dist/h3/_internal/request.js.map +0 -1
  325. package/dist/h3/_internal/retained.d.ts +0 -22
  326. package/dist/h3/_internal/retained.d.ts.map +0 -1
  327. package/dist/h3/_internal/retained.js +0 -89
  328. package/dist/h3/_internal/retained.js.map +0 -1
  329. package/dist/h3/_internal/state.d.ts +0 -44
  330. package/dist/h3/_internal/state.d.ts.map +0 -1
  331. package/dist/h3/_internal/state.js +0 -283
  332. package/dist/h3/_internal/state.js.map +0 -1
  333. package/dist/h3/index.d.ts +0 -10
  334. package/dist/h3/index.d.ts.map +0 -1
  335. package/dist/h3/index.js +0 -7
  336. package/dist/h3/index.js.map +0 -1
  337. package/dist/h3/messages.d.ts.map +0 -1
  338. package/dist/h3/messages.js.map +0 -1
  339. package/dist/h3/profile.d.ts.map +0 -1
  340. package/dist/h3/profile.js.map +0 -1
  341. package/dist/h3/types.d.ts +0 -291
  342. package/dist/h3/types.d.ts.map +0 -1
  343. package/dist/h3/types.js +0 -2
  344. package/dist/h3/types.js.map +0 -1
  345. package/dist/host.d.ts +0 -26
  346. package/dist/host.d.ts.map +0 -1
  347. package/dist/host.js +0 -20
  348. package/dist/host.js.map +0 -1
  349. package/dist/json.d.ts +0 -25
  350. package/dist/json.d.ts.map +0 -1
  351. package/dist/json.js +0 -143
  352. package/dist/json.js.map +0 -1
  353. package/dist/media-stream.d.ts +0 -10
  354. package/dist/media-stream.d.ts.map +0 -1
  355. package/dist/media-stream.js +0 -18
  356. package/dist/media-stream.js.map +0 -1
  357. package/dist/observation.d.ts +0 -38
  358. package/dist/observation.d.ts.map +0 -1
  359. package/dist/observation.js +0 -113
  360. package/dist/observation.js.map +0 -1
  361. package/dist/orchestration/elapsed.d.ts +0 -9
  362. package/dist/orchestration/elapsed.d.ts.map +0 -1
  363. package/dist/orchestration/elapsed.js +0 -8
  364. package/dist/orchestration/elapsed.js.map +0 -1
  365. package/dist/orchestration/h3-source.d.ts +0 -58
  366. package/dist/orchestration/h3-source.d.ts.map +0 -1
  367. package/dist/orchestration/h3-source.js +0 -362
  368. package/dist/orchestration/h3-source.js.map +0 -1
  369. package/dist/orchestration/index.d.ts +0 -17
  370. package/dist/orchestration/index.d.ts.map +0 -1
  371. package/dist/orchestration/index.js +0 -12
  372. package/dist/orchestration/index.js.map +0 -1
  373. package/dist/orchestration/media-buffer.d.ts +0 -32
  374. package/dist/orchestration/media-buffer.d.ts.map +0 -1
  375. package/dist/orchestration/media-buffer.js +0 -94
  376. package/dist/orchestration/media-buffer.js.map +0 -1
  377. package/dist/orchestration/open-h3.d.ts +0 -101
  378. package/dist/orchestration/open-h3.d.ts.map +0 -1
  379. package/dist/orchestration/open-h3.js +0 -127
  380. package/dist/orchestration/open-h3.js.map +0 -1
  381. package/dist/orchestration/queries.d.ts +0 -10
  382. package/dist/orchestration/queries.d.ts.map +0 -1
  383. package/dist/orchestration/queries.js +0 -43
  384. package/dist/orchestration/queries.js.map +0 -1
  385. package/dist/orchestration/references.d.ts +0 -18
  386. package/dist/orchestration/references.d.ts.map +0 -1
  387. package/dist/orchestration/references.js +0 -79
  388. package/dist/orchestration/references.js.map +0 -1
  389. package/dist/orchestration/renewal-state.d.ts +0 -84
  390. package/dist/orchestration/renewal-state.d.ts.map +0 -1
  391. package/dist/orchestration/renewal-state.js +0 -95
  392. package/dist/orchestration/renewal-state.js.map +0 -1
  393. package/dist/orchestration/renewal.d.ts +0 -103
  394. package/dist/orchestration/renewal.d.ts.map +0 -1
  395. package/dist/orchestration/renewal.js +0 -1088
  396. package/dist/orchestration/renewal.js.map +0 -1
  397. package/dist/orchestration/request.d.ts +0 -65
  398. package/dist/orchestration/request.d.ts.map +0 -1
  399. package/dist/orchestration/request.js +0 -134
  400. package/dist/orchestration/request.js.map +0 -1
  401. package/dist/orchestration/retention.d.ts +0 -100
  402. package/dist/orchestration/retention.d.ts.map +0 -1
  403. package/dist/orchestration/retention.js +0 -134
  404. package/dist/orchestration/retention.js.map +0 -1
  405. package/dist/orchestration/routing.d.ts +0 -21
  406. package/dist/orchestration/routing.d.ts.map +0 -1
  407. package/dist/orchestration/routing.js +0 -93
  408. package/dist/orchestration/routing.js.map +0 -1
  409. package/dist/orchestration/scheduler-key.d.ts +0 -14
  410. package/dist/orchestration/scheduler-key.d.ts.map +0 -1
  411. package/dist/orchestration/scheduler-key.js +0 -62
  412. package/dist/orchestration/scheduler-key.js.map +0 -1
  413. package/dist/orchestration/scheduler-policy.d.ts +0 -222
  414. package/dist/orchestration/scheduler-policy.d.ts.map +0 -1
  415. package/dist/orchestration/scheduler-policy.js +0 -582
  416. package/dist/orchestration/scheduler-policy.js.map +0 -1
  417. package/dist/orchestration/scheduler.d.ts +0 -383
  418. package/dist/orchestration/scheduler.d.ts.map +0 -1
  419. package/dist/orchestration/scheduler.js +0 -2597
  420. package/dist/orchestration/scheduler.js.map +0 -1
  421. package/dist/orchestration/source-slot.d.ts +0 -124
  422. package/dist/orchestration/source-slot.d.ts.map +0 -1
  423. package/dist/orchestration/source-slot.js +0 -348
  424. package/dist/orchestration/source-slot.js.map +0 -1
  425. package/dist/orchestration/types.d.ts +0 -1462
  426. package/dist/orchestration/types.d.ts.map +0 -1
  427. package/dist/orchestration/types.js +0 -135
  428. package/dist/orchestration/types.js.map +0 -1
  429. package/dist/protobuf.d.ts +0 -63
  430. package/dist/protobuf.d.ts.map +0 -1
  431. package/dist/protobuf.js +0 -252
  432. package/dist/protobuf.js.map +0 -1
  433. package/dist/session/_internal/acquire.d.ts +0 -21
  434. package/dist/session/_internal/acquire.d.ts.map +0 -1
  435. package/dist/session/_internal/acquire.js +0 -168
  436. package/dist/session/_internal/acquire.js.map +0 -1
  437. package/dist/session/_internal/cleanup.d.ts +0 -19
  438. package/dist/session/_internal/cleanup.d.ts.map +0 -1
  439. package/dist/session/_internal/cleanup.js +0 -102
  440. package/dist/session/_internal/cleanup.js.map +0 -1
  441. package/dist/session/_internal/lifecycle.d.ts +0 -67
  442. package/dist/session/_internal/lifecycle.d.ts.map +0 -1
  443. package/dist/session/_internal/lifecycle.js +0 -142
  444. package/dist/session/_internal/lifecycle.js.map +0 -1
  445. package/dist/session/_internal/remote.d.ts +0 -28
  446. package/dist/session/_internal/remote.d.ts.map +0 -1
  447. package/dist/session/_internal/remote.js +0 -71
  448. package/dist/session/_internal/remote.js.map +0 -1
  449. package/dist/session/_internal/uploads.d.ts +0 -7
  450. package/dist/session/_internal/uploads.d.ts.map +0 -1
  451. package/dist/session/_internal/uploads.js +0 -31
  452. package/dist/session/_internal/uploads.js.map +0 -1
  453. package/dist/session/commands.d.ts +0 -2
  454. package/dist/session/commands.d.ts.map +0 -1
  455. package/dist/session/commands.js +0 -2
  456. package/dist/session/commands.js.map +0 -1
  457. package/dist/session/files.d.ts +0 -21
  458. package/dist/session/files.d.ts.map +0 -1
  459. package/dist/session/files.js +0 -49
  460. package/dist/session/files.js.map +0 -1
  461. package/dist/session/index.d.ts +0 -84
  462. package/dist/session/index.d.ts.map +0 -1
  463. package/dist/session/index.js +0 -16
  464. package/dist/session/index.js.map +0 -1
  465. package/dist/session/media.d.ts +0 -114
  466. package/dist/session/media.d.ts.map +0 -1
  467. package/dist/session/media.js +0 -2
  468. package/dist/session/media.js.map +0 -1
  469. package/dist/session/recorder.d.ts +0 -24
  470. package/dist/session/recorder.d.ts.map +0 -1
  471. package/dist/session/recorder.js +0 -19
  472. package/dist/session/recorder.js.map +0 -1
  473. package/dist/session.d.ts +0 -105
  474. package/dist/session.d.ts.map +0 -1
  475. package/dist/session.js +0 -976
  476. package/dist/session.js.map +0 -1
  477. package/dist/simulation/_internal/source.d.ts +0 -12
  478. package/dist/simulation/_internal/source.d.ts.map +0 -1
  479. package/dist/simulation/_internal/source.js +0 -513
  480. package/dist/simulation/_internal/source.js.map +0 -1
  481. package/dist/simulation/index.d.ts +0 -13
  482. package/dist/simulation/index.d.ts.map +0 -1
  483. package/dist/simulation/index.js +0 -12
  484. package/dist/simulation/index.js.map +0 -1
  485. package/dist/simulation/types.d.ts +0 -39
  486. package/dist/simulation/types.d.ts.map +0 -1
  487. package/dist/simulation/types.js +0 -2
  488. package/dist/simulation/types.js.map +0 -1
  489. package/dist/stats.d.ts.map +0 -1
  490. package/dist/stats.js +0 -205
  491. package/dist/stats.js.map +0 -1
  492. package/dist/testing/Faults.d.ts +0 -4
  493. package/dist/testing/Faults.d.ts.map +0 -1
  494. package/dist/testing/Faults.js +0 -7
  495. package/dist/testing/Faults.js.map +0 -1
  496. package/dist/testing/Png.d.ts +0 -3
  497. package/dist/testing/Png.d.ts.map +0 -1
  498. package/dist/testing/Png.js.map +0 -1
  499. package/dist/testing/Wav.d.ts +0 -14
  500. package/dist/testing/Wav.d.ts.map +0 -1
  501. package/dist/testing/Wav.js +0 -37
  502. package/dist/testing/Wav.js.map +0 -1
  503. package/dist/testing/index.d.ts +0 -5
  504. package/dist/testing/index.d.ts.map +0 -1
  505. package/dist/testing/index.js +0 -4
  506. package/dist/testing/index.js.map +0 -1
  507. package/dist/wire.d.ts +0 -7
  508. package/dist/wire.d.ts.map +0 -1
  509. package/dist/wire.generated.d.ts +0 -305
  510. package/dist/wire.generated.d.ts.map +0 -1
  511. package/dist/wire.generated.js +0 -1257
  512. package/dist/wire.generated.js.map +0 -1
  513. package/dist/wire.js +0 -5
  514. package/dist/wire.js.map +0 -1
@@ -0,0 +1,2338 @@
1
+ /**
2
+ * The playout's pure policy: one state record and `step`, which applies one
3
+ * input and returns the new state, what to do, and when to be woken if nothing
4
+ * happens first. It holds no Effect, reads no clock and performs no I/O; the
5
+ * runtime supplies monotonic and wall time with every input.
6
+ *
7
+ * The plan is the single source of truth. Each session only executes its window
8
+ * of it: new work goes to the newest live session, Ready clips are ordered
9
+ * within their own session and never ranked across two, and each session has
10
+ * at most one provider command in flight, so a refusal is always attributable.
11
+ * Sessions' commands run side by side; what must follow across sessions, a
12
+ * switch or the removal of the retiring session's filler, waits on what the
13
+ * plan has seen, never on another session's command.
14
+ */
15
+ import { dual } from "effect/Function";
16
+ import { requestSeconds } from "../h3/profile.js";
17
+ /**
18
+ * Whether a command was refused unsent because its session was not in a state
19
+ * to take it, as when its connection drops before its source can say so. Only a
20
+ * change in what the session reports can mend that, so it waits for one.
21
+ */
22
+ const unready = (cause) => cause.context.outcome === "not-submitted" && cause.reason._tag === "InvalidState";
23
+ /** Whether a command that failed may have taken effect unseen. */
24
+ const uncertain = (result) => result._tag === "Died" || result.cause.context.outcome === "unknown";
25
+ export const initial = {
26
+ items: new Map(),
27
+ groups: new Map(),
28
+ settled: [],
29
+ nextOrder: 1,
30
+ sessions: [],
31
+ air: undefined,
32
+ opening: false,
33
+ openRetryAt: 0,
34
+ openingPaused: false,
35
+ setupFailures: 0,
36
+ countedFailures: 0,
37
+ moderations: 0,
38
+ nextCommand: 1,
39
+ filler: {
40
+ index: 0,
41
+ retries: [],
42
+ flagged: [],
43
+ retryAt: 0,
44
+ refilling: false,
45
+ },
46
+ batches: [],
47
+ joins: new Map(),
48
+ drains: [],
49
+ accepting: true,
50
+ closed: false,
51
+ samples: { build: [], continued: [], length: [] },
52
+ cut: undefined,
53
+ cutting: undefined,
54
+ starving: false,
55
+ starved: 0,
56
+ };
57
+ const retryDelayMs = 1_000;
58
+ /**
59
+ * The wait for the next open after `consecutive` failed setups in a row: a second for each, but
60
+ * no more than for `maxSetupFailures` of them, and at least one.
61
+ */
62
+ const setupDelayMs = (config, consecutive) => retryDelayMs * Math.min(consecutive, Math.max(1, config.maxSetupFailures));
63
+ const cutMarginMs = 1_000;
64
+ const exposureMarginMs = 1_500;
65
+ const lookaheadMarginSeconds = 1;
66
+ /**
67
+ * Looks one step takes at most. None in the property's scripts decided anything past its
68
+ * second look: the bound only keeps a fault from spinning the loop.
69
+ */
70
+ const maxLooks = 16;
71
+ const maxSamples = 32;
72
+ const minimumSamples = 3;
73
+ const quantile = (values, q) => {
74
+ const sorted = [...values].sort((a, b) => a - b);
75
+ return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0;
76
+ };
77
+ const spreadOf = (samples) => samples.length < minimumSamples
78
+ ? undefined
79
+ : { median: quantile(samples, 0.5), p95: quantile(samples, 0.95) };
80
+ const estimatesOf = (samples) => ({
81
+ build: spreadOf(samples.build),
82
+ continuedBuild: spreadOf(samples.continued),
83
+ length: samples.length.length === 0 ? 1 : quantile(samples.length, 0.5),
84
+ });
85
+ /**
86
+ * Until a continued build is measured, one is projected at this multiple of an
87
+ * independent build: on hosted H3 a continued 5 s clip took 5.45 s to build,
88
+ * against 2.1 to 2.2 s for independent ones (0.7.0 scheduler-edits run).
89
+ */
90
+ const continuedBuildFactor = 2.5;
91
+ /**
92
+ * Build seconds per requested second a continued build is projected at, erring
93
+ * long: the p95 of measured continued builds, or their longest below three
94
+ * samples; failing those, the same of independent builds times
95
+ * `continuedBuildFactor`. Undefined before any build was measured.
96
+ */
97
+ const continuedBuildRate = (samples) => {
98
+ const long = (values) => values.length >= minimumSamples ? quantile(values, 0.95) : Math.max(...values);
99
+ if (samples.continued.length > 0)
100
+ return long(samples.continued);
101
+ if (samples.build.length > 0)
102
+ return long(samples.build) * continuedBuildFactor;
103
+ return undefined;
104
+ };
105
+ /**
106
+ * The newest live session that can take work: new work goes there. One whose
107
+ * enqueue stayed unknown past the deadline takes none, since what it does with
108
+ * a command can no longer be told.
109
+ */
110
+ const preferredOf = (sessions) => [...sessions]
111
+ .reverse()
112
+ .find((value) => !value.retiring && !value.indeterminate && value.source?.available === true);
113
+ /**
114
+ * When `value`'s playing clip ends, counted from its observed start. Undefined for a clip of
115
+ * unknown length or whose start was not seen: what is left of it does not fall with the time.
116
+ */
117
+ const playingEndOf = (value) => {
118
+ const playing = value?.source?.playing;
119
+ const observed = value?.playing;
120
+ if (playing?.seconds === undefined || observed?.clipId !== playing.clipId)
121
+ return undefined;
122
+ return observed.at + playing.seconds * 1000;
123
+ };
124
+ /**
125
+ * What is left of `value`'s playing clip at `mono`, counted from its observed start. A clip
126
+ * of unknown length may end at any moment, so nothing is counted for it and the plan builds ahead.
127
+ */
128
+ const playingRestOf = (value, mono) => {
129
+ const playing = value?.source?.playing;
130
+ if (playing?.seconds === undefined)
131
+ return 0;
132
+ const end = playingEndOf(value);
133
+ return end === undefined ? playing.seconds * 1000 : Math.max(0, end - mono);
134
+ };
135
+ /**
136
+ * Whether a Ready clip airs when its turn comes: it isn't held, waiting on its
137
+ * batch or an anchor still ahead, or withdrawn. A clip that is no item's airs.
138
+ */
139
+ const airsOf = (items, clip, now) => {
140
+ const item = clip.tag?._tag === "Item" ? items.get(clip.tag.key) : undefined;
141
+ if (item === undefined)
142
+ return true;
143
+ const at = item.spec.start._tag === "At" ? now.mono + (item.spec.start.time - now.wall) : undefined;
144
+ return (item.mode !== "held" &&
145
+ item.batch === undefined &&
146
+ (at === undefined || at <= now.mono) &&
147
+ item.withdraw === undefined);
148
+ };
149
+ /**
150
+ * Seconds of air secured: the playing clip's rest, then the Ready clips that
151
+ * air from the session on air and from its replacement once that takes over.
152
+ */
153
+ const securedOf = (state, now) => {
154
+ const air = state.sessions.find((value) => value.id === state.air);
155
+ const replacement = state.sessions.find((value) => value.id !== state.air && !value.retiring);
156
+ const ready = (value) => (value?.source?.ready ?? [])
157
+ .filter((clip) => airsOf(state.items, clip, now))
158
+ .reduce((total, clip) => total + clip.seconds, 0);
159
+ return playingRestOf(air, now.mono) / 1000 + ready(air) + ready(replacement);
160
+ };
161
+ const compareRank = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3];
162
+ /** A group's answer from its parts': `withdrawn` if any was, else `already-started` if any started. */
163
+ const combined = (outcomes) => {
164
+ if (outcomes.includes("withdrawn"))
165
+ return "withdrawn";
166
+ return outcomes.includes("already-started") ? "already-started" : "not-found";
167
+ };
168
+ /** What a withdrawal finds of an item from its record: dropped, started, or neither. */
169
+ const fateOf = (item) => {
170
+ if (item.status?._tag === "Dropped")
171
+ return "withdrawn";
172
+ const started = item.startedAt !== undefined || item.phase === "Started" || item.status?._tag === "Ended";
173
+ return started ? "already-started" : "not-found";
174
+ };
175
+ /** One look at the plan: applies the input, then decides what is due at `now`. */
176
+ const decide = (config, previous, input, now) => {
177
+ const items = new Map(previous.items);
178
+ const groups = new Map(previous.groups);
179
+ let state = previous;
180
+ const actions = [];
181
+ const lanes = config.lanes.length;
182
+ const set = (key, patch) => {
183
+ const next = { ...items.get(key), ...patch };
184
+ items.set(key, next);
185
+ return next;
186
+ };
187
+ const emit = (event) => {
188
+ actions.push({ _tag: "Emit", event });
189
+ };
190
+ const asRun = (key, status) => {
191
+ set(key, { status });
192
+ emit({ _tag: "AsRun", event: { key, at: now.wall, status } });
193
+ };
194
+ const session = (id) => state.sessions.find((candidate) => candidate.id === id);
195
+ const updateSession = (id, patch) => {
196
+ state = {
197
+ ...state,
198
+ sessions: state.sessions.map((value) => (value.id === id ? { ...value, ...patch } : value)),
199
+ };
200
+ };
201
+ /** Records what a session's source reports, counting each change in whether it takes commands. */
202
+ const report = (id, source) => {
203
+ const previous = session(id);
204
+ const changed = (previous?.source?.available ?? false) !== source.available;
205
+ updateSession(id, { source, changes: (previous?.changes ?? 0) + (changed ? 1 : 0) });
206
+ };
207
+ /** The session new work goes to: the newest one that is live and not retiring. */
208
+ const preferred = () => preferredOf(state.sessions);
209
+ const atMono = (item) => item.spec.start._tag === "At" ? now.mono + (item.spec.start.time - now.wall) : undefined;
210
+ /**
211
+ * Answers a withdrawal waiting on one item. A group key's withdrawal waits on
212
+ * each part and answers once all have: `withdrawn` if any part was, else
213
+ * `already-started` if any started, as 0.7.0 answered it.
214
+ */
215
+ const answer = (waiter, outcome) => {
216
+ const id = `${String(waiter.id)}:${String(waiter.index)}`;
217
+ const join = state.joins.get(id);
218
+ if (join === undefined) {
219
+ actions.push({ _tag: "Withdrawn", id: waiter.id, index: waiter.index, outcome });
220
+ return;
221
+ }
222
+ const outcomes = [...join.outcomes, outcome];
223
+ const joins = new Map(state.joins);
224
+ if (join.left > 1)
225
+ joins.set(id, { left: join.left - 1, outcomes });
226
+ else
227
+ joins.delete(id);
228
+ state = { ...state, joins };
229
+ if (join.left > 1)
230
+ return;
231
+ actions.push({
232
+ _tag: "Withdrawn",
233
+ id: waiter.id,
234
+ index: waiter.index,
235
+ outcome: combined(outcomes),
236
+ });
237
+ };
238
+ /** Settles an item for good, resolving withdrawals that wait on it and recording history. */
239
+ const settle = (key, status) => {
240
+ const item = items.get(key);
241
+ if (item === undefined || item.phase === "Settled")
242
+ return;
243
+ // The as-run goes out first, so a withdrawal's caller finds it already published.
244
+ set(key, { phase: "Settled", waiting: [], withdraw: undefined });
245
+ asRun(key, status);
246
+ const outcome = fateOf({ ...item, status });
247
+ for (const wait of item.waiting)
248
+ answer(wait, outcome);
249
+ state = { ...state, settled: [...state.settled, key] };
250
+ // A part that fails or is dropped takes the parts after it with it; a replaced one does not,
251
+ // since its replacement takes its place.
252
+ if (item.group !== undefined &&
253
+ !item.inserted &&
254
+ (status._tag === "Failed" || (status._tag === "Dropped" && status.reason !== "replaced")))
255
+ for (const part of groups.get(item.group.key)?.parts ?? []) {
256
+ const other = items.get(part);
257
+ if (other?.group !== undefined && other.group.index > item.group.index)
258
+ withdraw(part, "withdrawn");
259
+ }
260
+ };
261
+ /**
262
+ * Withdraws an item the plan no longer wants. What has no clip goes at once;
263
+ * a clip is removed from its provider first, and a started one cannot go.
264
+ */
265
+ const withdraw = (key, reason, waiter) => {
266
+ const item = items.get(key);
267
+ if (item === undefined) {
268
+ if (waiter !== undefined)
269
+ answer(waiter, "not-found");
270
+ return;
271
+ }
272
+ if (item.phase === "Started" || (item.phase === "Settled" && item.startedAt !== undefined)) {
273
+ if (waiter !== undefined)
274
+ answer(waiter, "already-started");
275
+ return;
276
+ }
277
+ if (item.phase === "Settled") {
278
+ if (waiter !== undefined)
279
+ answer(waiter, item.status?._tag === "Dropped" ? "withdrawn" : "not-found");
280
+ return;
281
+ }
282
+ const waiting = waiter === undefined ? item.waiting : [...item.waiting, waiter];
283
+ if (item.phase === "Accepted") {
284
+ set(key, { waiting });
285
+ settle(key, { _tag: "Dropped", reason });
286
+ return;
287
+ }
288
+ set(key, { withdraw: item.withdraw ?? reason, waiting });
289
+ };
290
+ const live = (item) => item !== undefined && item.phase !== "Settled";
291
+ const readyOf = (value) => value.source?.ready ?? [];
292
+ /** Whether a session still holds the air: it takes new work, or has clips of its own to air. */
293
+ const holding = (value) => value !== undefined &&
294
+ (!value.indeterminate || value.source?.playing !== undefined || readyOf(value).length > 0);
295
+ const itemOf = (clip) => clip?.tag?._tag === "Item" ? items.get(clip.tag.key) : undefined;
296
+ // Ranks: lexicographic places in a session's Ready order and in the build order.
297
+ const superseded = new Set(state.batches.flatMap((batch) => batch.targets.map((target) => target.key)));
298
+ const begun = (item) => item.group !== undefined &&
299
+ (groups.get(item.group.key)?.parts ?? []).some((part) => {
300
+ const other = items.get(part);
301
+ return other?.startedAt !== undefined || other?.phase === "Started";
302
+ });
303
+ /** The item still in the place a replacement takes; replaced replacements lead back to it. */
304
+ const replacedOf = (item) => {
305
+ let old = item.replaces === undefined ? undefined : items.get(item.replaces);
306
+ while (old?.phase === "Settled" && old.startedAt === undefined && old.replaces !== undefined)
307
+ old = items.get(old.replaces);
308
+ return old;
309
+ };
310
+ const rankItem = (item) => {
311
+ const at = atMono(item);
312
+ if (item.mode === "held" || (at !== undefined && now.mono < at) || item.batch !== undefined)
313
+ return [lanes + 1, 1, item.order, item.generation];
314
+ // Its build continues the Ready clip it follows on its session, so it airs right behind that
315
+ // clip until that clip starts. An item accepted again for a rebuild follows nothing yet.
316
+ const followed = item.follows === undefined || item.phase === "Accepted"
317
+ ? undefined
318
+ : session(item.sessionId)?.source?.ready.find((clip) => clip.clipId === item.follows);
319
+ if (followed !== undefined) {
320
+ const behind = rankClip(followed);
321
+ return [behind[0], behind[1], behind[2], behind[3] + 0.5];
322
+ }
323
+ if (item.mode === "asap")
324
+ return [-0.5, 0, item.order, item.generation];
325
+ const displaced = [...items.values()].some((other) => (other.phase === "Ready" || other.phase === "Started") &&
326
+ other.batch === undefined &&
327
+ replacedOf(other)?.spec.key === item.spec.key);
328
+ return [
329
+ item.spec.lane,
330
+ superseded.has(item.spec.key) ? 2 : begun(item) ? 0 : 1,
331
+ item.order,
332
+ displaced ? Infinity : item.generation,
333
+ ];
334
+ };
335
+ const rankClip = (clip) => {
336
+ if (clip.tag === undefined)
337
+ return [-1, 0, 0, 0];
338
+ if (clip.tag._tag === "Filler")
339
+ return [lanes, 1, clip.tag.index, 0];
340
+ const item = items.get(clip.tag.key);
341
+ return item === undefined ? [-1, 0, 0, 0] : rankItem(item);
342
+ };
343
+ /** Which of two waiting items takes the build slot first. */
344
+ const buildOrder = (a, b) => Number(b.mode === "asap") - Number(a.mode === "asap") ||
345
+ Number(a.mode === "held") - Number(b.mode === "held") ||
346
+ a.spec.lane - b.spec.lane ||
347
+ Number(b.replaces !== undefined) - Number(a.replaces !== undefined) ||
348
+ Number(b.group !== undefined && b.group.index > 0) -
349
+ Number(a.group !== undefined && a.group.index > 0) ||
350
+ (a.startBy ?? Infinity) - (b.startBy ?? Infinity) ||
351
+ a.order - b.order;
352
+ /** A group's part may build only once the part before it was admitted. */
353
+ const previousAdmitted = (item) => {
354
+ // An insert waits for whatever airs just before it in its lane to be admitted.
355
+ if (item.inserted) {
356
+ const before = [...items.values()]
357
+ .filter((other) => live(other) && other.spec.lane === item.spec.lane && other.order < item.order)
358
+ .sort((a, b) => b.order - a.order)[0];
359
+ return before?.phase !== "Accepted";
360
+ }
361
+ if (item.group === undefined)
362
+ return true;
363
+ const index = item.group.index;
364
+ return !(groups.get(item.group.key)?.parts ?? []).some((part) => {
365
+ const other = items.get(part);
366
+ return (other?.group !== undefined &&
367
+ !other.inserted &&
368
+ other.group.index === index - 1 &&
369
+ other.phase === "Accepted");
370
+ });
371
+ };
372
+ const estimates = () => estimatesOf(state.samples);
373
+ const playingRestMs = (value) => playingRestOf(value, now.mono);
374
+ const airs = (clip) => airsOf(items, clip, now);
375
+ /**
376
+ * The runway as the time passes: at `time` it is `(Math.max(end, time) - time) / 1000 +
377
+ * seconds`. It falls while the clip on air plays, to that clip's end, and holds still with
378
+ * none, or with one whose length or start is unknown, counted as `playingRestOf` counts it.
379
+ */
380
+ const runwayTerms = () => {
381
+ const target = preferred() ?? session(state.air);
382
+ if (target === undefined)
383
+ return { end: -Infinity, seconds: 0 };
384
+ const onAir = target.id === state.air;
385
+ const end = onAir ? playingEndOf(target) : undefined;
386
+ const ready = readyOf(target)
387
+ .filter(airs)
388
+ .reduce((total, clip) => total + clip.seconds, 0);
389
+ return end === undefined
390
+ ? { end: -Infinity, seconds: (onAir ? playingRestMs(target) / 1000 : 0) + ready }
391
+ : { end, seconds: ready };
392
+ };
393
+ /** Seconds of air secured on the session that takes new work. */
394
+ const runway = () => {
395
+ const { end, seconds } = runwayTerms();
396
+ return (Math.max(end, now.mono) - now.mono) / 1000 + seconds;
397
+ };
398
+ /** When the runway falls to `seconds` as the clip on air plays; undefined if it doesn't. */
399
+ const fallsTo = (seconds) => {
400
+ const { end, seconds: after } = runwayTerms();
401
+ return Number.isFinite(end) && seconds > after ? end - (seconds - after) * 1000 : undefined;
402
+ };
403
+ /** Whether the runway is below `seconds`: it is from the instant it falls there. */
404
+ const below = (seconds) => runway() < seconds || now.mono >= (fallsTo(seconds) ?? Infinity);
405
+ /**
406
+ * The earliest a clip in `item`'s place could start, optimistically, as the time passes: at
407
+ * `time` it is `Math.max(from, time + after)`. The end of the clip on air and the builds in
408
+ * flight fix `from`; the air ahead and the builds it waits for add `after` to the time, as
409
+ * they do once that clip is over.
410
+ */
411
+ const projection = (item) => {
412
+ const target = preferred() ?? session(state.air);
413
+ const rank = rankItem(item);
414
+ const aheadMs = (target === undefined ? [] : readyOf(target))
415
+ .filter((clip) => compareRank(rankClip(clip), rank) < 0 && itemOf(clip)?.withdraw === undefined)
416
+ .reduce((total, clip) => total + clip.seconds * 1000, 0);
417
+ const air = session(state.air);
418
+ const end = playingEndOf(air);
419
+ const playable = {
420
+ from: (end ?? -Infinity) + aheadMs,
421
+ after: (end === undefined ? playingRestMs(air) : 0) + aheadMs,
422
+ };
423
+ const perSecond = estimates().build?.median;
424
+ if (perSecond === undefined)
425
+ return playable;
426
+ const buildMs = (seconds) => seconds * perSecond * 1000;
427
+ const inFlight = [
428
+ ...[...items.values()].flatMap((other) => other.phase === "Building" &&
429
+ other.dispatchedAt !== undefined &&
430
+ other.spec.key !== item.spec.key
431
+ ? [other.dispatchedAt + buildMs(other.spec.seconds)]
432
+ : []),
433
+ ...(target?.fillerSent === undefined
434
+ ? []
435
+ : [target.fillerSent.at + buildMs(target.fillerSent.seconds)]),
436
+ ];
437
+ const first = [...items.values()]
438
+ .filter((other) => other.phase === "Accepted" &&
439
+ other.spec.key !== item.spec.key &&
440
+ other.withdraw === undefined &&
441
+ other.mode !== "held" &&
442
+ (atMono(other) ?? -Infinity) <= now.mono &&
443
+ (other.notBefore ?? -Infinity) <= now.mono &&
444
+ previousAdmitted(other) &&
445
+ buildOrder(other, item) < 0)
446
+ .reduce((total, other) => total + buildMs(other.spec.seconds), 0);
447
+ const waits = first + buildMs(item.spec.seconds);
448
+ return {
449
+ from: Math.max(playable.from, Math.max(...inFlight) + waits),
450
+ after: Math.max(playable.after, waits),
451
+ };
452
+ };
453
+ /**
454
+ * Whether a clip in `item`'s place could not start before `startBy`, as projected. Once
455
+ * nothing holds it, the projection grows with the time: it misses from `startBy - after` on.
456
+ */
457
+ const misses = (item, startBy) => {
458
+ const { from, after } = projection(item);
459
+ return Math.max(from, now.mono + after) >= startBy || now.mono >= startBy - after;
460
+ };
461
+ /** Whether `item` is firm, not sent, and dropped once projected to miss its `startBy`. */
462
+ const lateWhenProjected = (item) => item.phase === "Accepted" &&
463
+ item.spec.window?.firm === true &&
464
+ item.startBy !== undefined &&
465
+ item.dispatchedAt === undefined &&
466
+ estimates().build !== undefined;
467
+ const newItem = (spec, place = {}) => ({
468
+ spec,
469
+ order: state.nextOrder,
470
+ generation: 0,
471
+ inserted: false,
472
+ mode: spec.start._tag === "Asap" ? "asap" : spec.start._tag === "Manual" ? "held" : "follow",
473
+ phase: "Accepted",
474
+ notBefore: spec.window?.notBeforeMs === undefined ? undefined : now.mono + spec.window.notBeforeMs,
475
+ startBy: spec.window?.startByMs === undefined ? undefined : now.mono + spec.window.startByMs,
476
+ everUnknown: false,
477
+ unbuiltLosses: 0,
478
+ fired: [],
479
+ waiting: [],
480
+ ...place,
481
+ });
482
+ const applyEdit = (id, edits, batched) => {
483
+ // A drain stops admissions, not withdrawals.
484
+ if (state.closed || (!state.accepting && edits.some((edit) => edit._tag !== "Withdraw"))) {
485
+ actions.push({ _tag: "Refused", id, refusal: { _tag: "PlayoutClosed" } });
486
+ return;
487
+ }
488
+ const refuse = (refusal) => {
489
+ actions.push({ _tag: "Refused", id, refusal });
490
+ };
491
+ const seen = new Set();
492
+ // Check every edit before any takes effect.
493
+ for (const edit of edits) {
494
+ const keys = edit._tag === "SubmitGroup"
495
+ ? [edit.key, ...edit.parts.map((part) => part.key)]
496
+ : edit._tag === "Withdraw"
497
+ ? []
498
+ : [edit.spec.key];
499
+ for (const key of keys) {
500
+ if (seen.has(key))
501
+ return refuse({ _tag: "InvalidItem", key, message: "a key appears twice in one batch" });
502
+ seen.add(key);
503
+ const existing = items.get(key);
504
+ const fingerprint = edit._tag === "SubmitGroup" && key === edit.key
505
+ ? edit.fingerprint
506
+ : edit._tag === "SubmitGroup"
507
+ ? edit.parts.find((part) => part.key === key)?.fingerprint
508
+ : edit._tag === "Withdraw"
509
+ ? undefined
510
+ : edit.spec.fingerprint;
511
+ const group = groups.get(key);
512
+ if ((existing !== undefined && existing.spec.fingerprint !== fingerprint) ||
513
+ (group !== undefined && group.fingerprint !== fingerprint) ||
514
+ // An insert's place is part of it; a new group cannot take another item's key as a part.
515
+ (edit._tag === "Insert" &&
516
+ existing !== undefined &&
517
+ (existing.anchor?.key !== edit.anchor || existing.anchor.side !== edit.side)) ||
518
+ (edit._tag === "SubmitGroup" &&
519
+ key !== edit.key &&
520
+ existing !== undefined &&
521
+ !groups.has(edit.key)))
522
+ return refuse({ _tag: "KeyMismatch", key: key });
523
+ }
524
+ if (edit._tag === "Insert") {
525
+ const anchor = items.get(edit.anchor) ?? firstOrLastPart(edit.anchor, edit.side);
526
+ // After the playing item is the next boundary; before it is already past.
527
+ if (!live(anchor) || (anchor.phase === "Started" && edit.side === "before"))
528
+ return refuse({
529
+ _tag: "InvalidItem",
530
+ key: edit.spec.key,
531
+ message: "the anchor is not waiting to air",
532
+ });
533
+ if (anchor.mode === "held")
534
+ return refuse({
535
+ _tag: "InvalidItem",
536
+ key: edit.spec.key,
537
+ message: "a held Manual item cannot anchor an insert",
538
+ });
539
+ }
540
+ if (edit._tag === "Replace" && !live(items.get(edit.key)) && !items.has(edit.spec.key))
541
+ return refuse({
542
+ _tag: "InvalidItem",
543
+ key: edit.key,
544
+ message: "no waiting item has this key",
545
+ });
546
+ if (edit._tag === "Submit" && !items.has(edit.spec.key)) {
547
+ const lane = config.lanes[edit.spec.lane];
548
+ if (lane?.conflict === "skip" &&
549
+ [...items.values()].some((item) => item.spec.lane === edit.spec.lane && live(item)))
550
+ return refuse({ _tag: "LaneBusy", key: edit.spec.key, lane: edit.spec.lane });
551
+ }
552
+ }
553
+ // An edit refused below, as an item it adds would miss its deadline, leaves the plan as it was.
554
+ const saved = { items: new Map(items), groups: new Map(groups), nextOrder: state.nextOrder };
555
+ const results = [];
556
+ const adds = [];
557
+ const targets = [];
558
+ /** Group-key withdrawals by position, and how many parts each waits on. */
559
+ const joined = [];
560
+ /** Withdrawals of keys the plan does not know, answered once the edit is accepted. */
561
+ const notFound = [];
562
+ const put = (item) => {
563
+ items.set(item.spec.key, item);
564
+ adds.push(item.spec.key);
565
+ state = { ...state, nextOrder: state.nextOrder + 1 };
566
+ };
567
+ for (const [index, edit] of edits.entries()) {
568
+ switch (edit._tag) {
569
+ case "Submit": {
570
+ if (!items.has(edit.spec.key)) {
571
+ const lane = config.lanes[edit.spec.lane];
572
+ if (lane?.conflict === "replace") {
573
+ batched = true;
574
+ for (const other of items.values())
575
+ if (other.spec.lane === edit.spec.lane &&
576
+ (other.phase === "Accepted" ||
577
+ other.phase === "Building" ||
578
+ other.phase === "Ready" ||
579
+ other.phase === "Unknown"))
580
+ targets.push({ key: other.spec.key, index: -1, reason: "replaced" });
581
+ }
582
+ put(newItem(edit.spec));
583
+ }
584
+ results.push({ _tag: "Added", key: edit.spec.key });
585
+ break;
586
+ }
587
+ case "SubmitGroup": {
588
+ if (!groups.has(edit.key)) {
589
+ groups.set(edit.key, {
590
+ fingerprint: edit.fingerprint,
591
+ parts: edit.parts.map((part) => part.key),
592
+ });
593
+ edit.parts.forEach((part, partIndex) => {
594
+ const { window: _window, ...rest } = part;
595
+ put(newItem(partIndex === 0 ? part : rest, {
596
+ group: { key: edit.key, index: partIndex },
597
+ }));
598
+ });
599
+ }
600
+ results.push({
601
+ _tag: "AddedGroup",
602
+ key: edit.key,
603
+ parts: edit.parts.map((part) => part.key),
604
+ });
605
+ break;
606
+ }
607
+ case "Insert": {
608
+ if (!items.has(edit.spec.key)) {
609
+ const anchor = items.get(edit.anchor) ?? firstOrLastPart(edit.anchor, edit.side);
610
+ const sameLane = [...items.values()]
611
+ .filter((other) => other.spec.lane === anchor.spec.lane && other !== anchor)
612
+ .map((other) => other.order);
613
+ const neighbour = edit.side === "before"
614
+ ? Math.max(anchor.order - 1, ...sameLane.filter((order) => order < anchor.order))
615
+ : Math.min(anchor.order + 1, ...sameLane.filter((order) => order > anchor.order));
616
+ // A playing anchor's start is spent: the insert follows it at the next boundary.
617
+ const playing = anchor.phase === "Started";
618
+ put(newItem({
619
+ ...edit.spec,
620
+ lane: anchor.spec.lane,
621
+ start: playing ? { _tag: "Follow" } : anchor.spec.start,
622
+ }, {
623
+ order: (anchor.order + neighbour) / 2,
624
+ group: anchor.group === undefined
625
+ ? undefined
626
+ : { key: anchor.group.key, index: anchor.group.index },
627
+ inserted: true,
628
+ anchor: { key: edit.anchor, side: edit.side },
629
+ mode: playing ? "follow" : anchor.mode,
630
+ }));
631
+ state = { ...state, nextOrder: state.nextOrder - 1 };
632
+ }
633
+ results.push({ _tag: "Added", key: edit.spec.key });
634
+ break;
635
+ }
636
+ case "Replace": {
637
+ if (!items.has(edit.spec.key)) {
638
+ const old = items.get(edit.key);
639
+ put(newItem({ ...edit.spec, lane: old.spec.lane, start: old.spec.start }, {
640
+ order: old.order,
641
+ generation: old.generation + 1,
642
+ group: old.group,
643
+ inserted: old.inserted,
644
+ replaces: old.spec.key,
645
+ mode: old.mode,
646
+ startBy: old.startBy,
647
+ }));
648
+ state = { ...state, nextOrder: state.nextOrder - 1 };
649
+ // A replacement takes its part's place: withdrawing the group or that place reaches it.
650
+ const group = old.group === undefined || old.inserted ? undefined : groups.get(old.group.key);
651
+ if (old.group !== undefined && group !== undefined)
652
+ groups.set(old.group.key, { ...group, parts: [...group.parts, edit.spec.key] });
653
+ }
654
+ results.push({ _tag: "Added", key: edit.spec.key });
655
+ break;
656
+ }
657
+ case "Withdraw": {
658
+ const group = groups.get(edit.key);
659
+ const keys = group === undefined ? [edit.key] : group.parts;
660
+ const known = keys.some((key) => items.has(key));
661
+ if (!known)
662
+ notFound.push(index);
663
+ else if (group !== undefined) {
664
+ // A group key withdraws every part, and answers once each part has.
665
+ joined.push({ index, parts: group.parts.length });
666
+ for (const key of group.parts)
667
+ targets.push({ key, index, reason: "withdrawn" });
668
+ }
669
+ else if (items.get(edit.key)?.group !== undefined && !items.get(edit.key).inserted) {
670
+ // A part key withdraws that part and every part after it, and answers for that part.
671
+ const part = items.get(edit.key);
672
+ for (const other of groups.get(part.group.key)?.parts ?? [])
673
+ if ((items.get(other)?.group?.index ?? -1) >= part.group.index)
674
+ targets.push({
675
+ key: other,
676
+ index: other === edit.key ? index : -1,
677
+ reason: "withdrawn",
678
+ });
679
+ }
680
+ else
681
+ targets.push({ key: edit.key, index, reason: "withdrawn" });
682
+ results.push({ _tag: "Withdrawal" });
683
+ break;
684
+ }
685
+ }
686
+ }
687
+ for (const key of adds) {
688
+ const item = items.get(key);
689
+ if (item.spec.window?.firm === true &&
690
+ item.startBy !== undefined &&
691
+ item.group?.index !== undefined &&
692
+ item.group.index > 0)
693
+ continue;
694
+ if (item.spec.window?.firm === true &&
695
+ item.startBy !== undefined &&
696
+ misses(item, item.startBy)) {
697
+ items.clear();
698
+ for (const [other, value] of saved.items)
699
+ items.set(other, value);
700
+ groups.clear();
701
+ for (const [group, value] of saved.groups)
702
+ groups.set(group, value);
703
+ state = { ...state, nextOrder: saved.nextOrder };
704
+ return refuse({ _tag: "WouldMissDeadline", key });
705
+ }
706
+ }
707
+ for (const index of notFound)
708
+ actions.push({ _tag: "Withdrawn", id, index, outcome: "not-found" });
709
+ if (joined.length > 0) {
710
+ const joins = new Map(state.joins);
711
+ for (const join of joined)
712
+ joins.set(`${String(id)}:${String(join.index)}`, { left: join.parts, outcomes: [] });
713
+ state = { ...state, joins };
714
+ }
715
+ const pending = batched && (adds.length > 0 || targets.length > 0);
716
+ for (const key of adds) {
717
+ set(key, { batch: pending && batched ? id : undefined });
718
+ asRun(key, { _tag: "Accepted" });
719
+ }
720
+ actions.push({ _tag: "Accepted", id, results });
721
+ if (pending || targets.length > 0) {
722
+ // What nothing was built for covers nothing, so it goes at once, and is answered once.
723
+ const later = targets.filter((target) => items.get(target.key)?.phase !== "Accepted");
724
+ for (const target of targets)
725
+ if (!later.includes(target))
726
+ withdraw(target.key, target.reason, target.index >= 0 ? { id, index: target.index } : undefined);
727
+ state = { ...state, batches: [...state.batches, { id, adds, targets: later }] };
728
+ }
729
+ else
730
+ actions.push({ _tag: "Committed", id });
731
+ };
732
+ /** A group's first or last waiting part by place; a replacement shares its part's place. */
733
+ function firstOrLastPart(key, side) {
734
+ const parts = (groups
735
+ .get(key)
736
+ ?.parts.map((part) => items.get(part))
737
+ .filter(live) ?? []).sort((a, b) => (a.group?.index ?? 0) - (b.group?.index ?? 0) || a.generation - b.generation);
738
+ return side === "before" ? parts[0] : parts[parts.length - 1];
739
+ }
740
+ /** Records the clip a session plays, and when a new one's start was seen. */
741
+ const nowPlaying = (sessionId, clip) => {
742
+ const playing = session(sessionId)?.playing;
743
+ // A later report of the same clip may name what an earlier one could not.
744
+ if (playing?.clipId === clip.clipId) {
745
+ updateSession(sessionId, {
746
+ playing: {
747
+ ...playing,
748
+ tag: clip.tag ?? playing.tag,
749
+ seconds: clip.seconds ?? playing.seconds,
750
+ },
751
+ });
752
+ return;
753
+ }
754
+ // A session's first clip on air ends a run of sessions that failed to set up or to play
755
+ // anything. A later clip on a session that already aired says nothing of those.
756
+ if (session(sessionId)?.startedAny === false)
757
+ state = { ...state, setupFailures: 0, countedFailures: 0 };
758
+ updateSession(sessionId, {
759
+ startedAny: true,
760
+ playing: { ...clip, at: now.mono, wall: now.wall },
761
+ });
762
+ if (clip.tag?._tag === "Filler")
763
+ emit({
764
+ _tag: "Filler",
765
+ index: clip.tag.index,
766
+ phase: "Started",
767
+ at: now.wall,
768
+ seconds: clip.seconds,
769
+ });
770
+ };
771
+ const started = (sessionId, clip) => {
772
+ nowPlaying(sessionId, clip);
773
+ if (clip.tag?._tag !== "Item")
774
+ return;
775
+ const item = items.get(clip.tag.key);
776
+ if (item === undefined || item.startedAt !== undefined || item.phase === "Settled")
777
+ return;
778
+ const lateBy = item.startBy === undefined ? undefined : now.mono - item.startBy;
779
+ const at = atMono(item);
780
+ const late = lateBy !== undefined && lateBy > 0
781
+ ? lateBy
782
+ : at !== undefined && now.mono > at
783
+ ? now.mono - at
784
+ : undefined;
785
+ set(clip.tag.key, {
786
+ phase: "Started",
787
+ startedAt: now.mono,
788
+ clipId: clip.clipId,
789
+ sessionId,
790
+ waiting: [],
791
+ withdraw: undefined,
792
+ airSeconds: clip.seconds,
793
+ });
794
+ asRun(clip.tag.key, {
795
+ _tag: "Started",
796
+ at: now.wall,
797
+ sessionId,
798
+ // A clip the provider named without its length is counted at the length requested.
799
+ seconds: clip.seconds ?? item.spec.seconds,
800
+ ...(late === undefined ? {} : { lateByMillis: Math.round(late) }),
801
+ });
802
+ // A withdrawal that waited on it is too late: it answers now, not when the clip ends.
803
+ for (const wait of item.waiting)
804
+ answer(wait, "already-started");
805
+ };
806
+ const forgetFiller = (sessionId, clipId) => updateSession(sessionId, {
807
+ fillers: new Map([...(session(sessionId)?.fillers ?? [])].filter(([id]) => id !== clipId)),
808
+ });
809
+ const ended = (sessionId, event) => {
810
+ const { clip } = event;
811
+ updateSession(sessionId, {
812
+ lastEndedAt: now.mono,
813
+ ...(session(sessionId)?.playing?.clipId === clip.clipId ? { playing: undefined } : {}),
814
+ });
815
+ if (clip.tag?._tag === "Filler") {
816
+ emit({
817
+ _tag: "Filler",
818
+ index: clip.tag.index,
819
+ phase: "Ended",
820
+ at: now.wall,
821
+ seconds: clip.seconds,
822
+ });
823
+ return forgetFiller(sessionId, clip.clipId);
824
+ }
825
+ const item = itemOf(clip);
826
+ if (item === undefined || item.phase === "Settled")
827
+ return;
828
+ if (item.startedAt === undefined) {
829
+ settle(item.spec.key, { _tag: "Unobserved" });
830
+ return;
831
+ }
832
+ const aired = event.airedSeconds ?? (now.mono - item.startedAt) / 1000;
833
+ settle(item.spec.key, {
834
+ _tag: "Ended",
835
+ at: now.wall,
836
+ termination: event.termination,
837
+ airedSeconds: aired,
838
+ });
839
+ state = {
840
+ ...state,
841
+ samples: {
842
+ ...state.samples,
843
+ length: [...state.samples.length, clip.seconds / item.spec.seconds].slice(-maxSamples),
844
+ },
845
+ };
846
+ };
847
+ /**
848
+ * A moderation verdict. On `terminate` the latest enqueue sent to the session
849
+ * is held to blame: it fails for good rather than be rebuilt on the next
850
+ * session and flagged again, and enough such endings fail the playout.
851
+ */
852
+ const moderated = (sessionId, event) => {
853
+ const terminate = event.action === "terminate";
854
+ const suspect = terminate ? session(sessionId)?.lastEnqueue : undefined;
855
+ const key = suspect?._tag === "Item" ? suspect.key : undefined;
856
+ emit({
857
+ _tag: "Session",
858
+ event: {
859
+ _tag: "Moderated",
860
+ sessionId,
861
+ action: event.action,
862
+ categories: event.categories,
863
+ ...(key === undefined ? {} : { key }),
864
+ },
865
+ });
866
+ if (!terminate)
867
+ return;
868
+ if (key !== undefined)
869
+ settle(key, {
870
+ _tag: "Failed",
871
+ reason: { _tag: "Moderated", categories: event.categories },
872
+ });
873
+ // A flagged filler request is not asked for again.
874
+ if (suspect?._tag === "Filler")
875
+ state = {
876
+ ...state,
877
+ filler: {
878
+ ...state.filler,
879
+ retries: state.filler.retries.filter((retry) => retry.index !== suspect.index),
880
+ flagged: [...state.filler.flagged, suspect.index],
881
+ },
882
+ };
883
+ const moderations = state.moderations + 1;
884
+ state = { ...state, moderations };
885
+ if (moderations >= config.maxModerations)
886
+ actions.push({
887
+ _tag: "Fail",
888
+ reason: `content moderation ended ${String(moderations)} sessions`,
889
+ cause: "moderation",
890
+ });
891
+ };
892
+ const failed = (sessionId, event) => {
893
+ const { clip } = event;
894
+ // A clip that fails on air leaves it as an ended one does, and the switch's grace counts from now.
895
+ const onAir = session(sessionId)?.playing?.clipId === clip.clipId;
896
+ if (onAir)
897
+ updateSession(sessionId, { lastEndedAt: now.mono, playing: undefined });
898
+ if (clip.tag?._tag === "Filler") {
899
+ if (onAir)
900
+ emit({
901
+ _tag: "Filler",
902
+ index: clip.tag.index,
903
+ phase: "Ended",
904
+ at: now.wall,
905
+ seconds: clip.seconds,
906
+ });
907
+ return forgetFiller(sessionId, clip.clipId);
908
+ }
909
+ const item = itemOf(clip);
910
+ if (item !== undefined)
911
+ settle(item.spec.key, {
912
+ _tag: "Failed",
913
+ reason: { _tag: "Clip", message: event.message, provider: event.provider },
914
+ });
915
+ };
916
+ /** Reads a session's queues back into the plan: adoption by key, Ready, and clips that vanished. */
917
+ const observe = (sessionId, source) => {
918
+ report(sessionId, source);
919
+ // What plays is what the provider reports while its report is current; a clip seen playing
920
+ // without its start event started no later than now. H3 answers a start or an end with the
921
+ // facts it held before it, and says so: they would name the clip that played before.
922
+ if (source.available) {
923
+ if (source.playing === undefined)
924
+ updateSession(sessionId, { playing: undefined });
925
+ else
926
+ nowPlaying(sessionId, source.playing);
927
+ }
928
+ const listed = new Map();
929
+ for (const clip of source.building)
930
+ listed.set(clip.clipId, "Building");
931
+ for (const clip of source.ready)
932
+ listed.set(clip.clipId, "Ready");
933
+ if (source.playing !== undefined)
934
+ listed.set(source.playing.clipId, "Playing");
935
+ const fillers = new Map(session(sessionId)?.fillers);
936
+ const sample = (kind, dispatchedAt, seconds) => {
937
+ const value = (now.mono - dispatchedAt) / 1000 / seconds;
938
+ state = {
939
+ ...state,
940
+ samples: { ...state.samples, [kind]: [...state.samples[kind], value].slice(-maxSamples) },
941
+ };
942
+ };
943
+ for (const clip of [
944
+ ...source.building,
945
+ ...source.ready,
946
+ ...(source.playing === undefined ? [] : [source.playing]),
947
+ ]) {
948
+ if (clip.tag?._tag === "Filler") {
949
+ // A filler build is measured as an item's is, so a channel airing only filler learns its
950
+ // build rate, and the floor covers a build.
951
+ const build = fillers.get(clip.clipId)?.build;
952
+ const where = listed.get(clip.clipId);
953
+ if (build !== undefined && where === "Ready")
954
+ sample("build", build.dispatchedAt, build.seconds);
955
+ fillers.set(clip.clipId, {
956
+ index: clip.tag.index,
957
+ build: where === "Building" ? build : undefined,
958
+ });
959
+ }
960
+ if (clip.tag?._tag !== "Item")
961
+ continue;
962
+ const item = items.get(clip.tag.key);
963
+ if (item === undefined || item.phase === "Settled")
964
+ continue;
965
+ const where = listed.get(clip.clipId);
966
+ if (where !== "Playing" &&
967
+ item.discarded?.sessionId === sessionId &&
968
+ item.discarded.clipId === clip.clipId)
969
+ continue;
970
+ if (where === "Playing") {
971
+ if (item.startedAt === undefined) {
972
+ if (item.clipId === undefined)
973
+ set(item.spec.key, { clipId: clip.clipId, sessionId });
974
+ started(sessionId, clip);
975
+ }
976
+ continue;
977
+ }
978
+ // Evidence only moves an item forward: a queue snapshot older than its start cannot undo it.
979
+ const waiting = item.phase === "Accepted" || item.phase === "Building" || item.phase === "Unknown";
980
+ if (where === "Ready" && waiting) {
981
+ // A continued build takes longer, so it is measured apart from independent ones.
982
+ if (item.dispatchedAt !== undefined && !item.everUnknown)
983
+ sample(item.continued === true ? "continued" : "build", item.dispatchedAt, item.spec.seconds);
984
+ set(item.spec.key, {
985
+ phase: "Ready",
986
+ clipId: clip.clipId,
987
+ sessionId,
988
+ dispatchedAt: undefined,
989
+ unknownSince: undefined,
990
+ unbuiltLosses: 0,
991
+ });
992
+ asRun(item.spec.key, { _tag: "Ready", sessionId });
993
+ }
994
+ else if (where === "Building" &&
995
+ waiting &&
996
+ (item.phase !== "Building" || item.clipId === undefined)) {
997
+ set(item.spec.key, {
998
+ phase: "Building",
999
+ clipId: clip.clipId,
1000
+ sessionId,
1001
+ unknownSince: undefined,
1002
+ });
1003
+ asRun(item.spec.key, { _tag: "Building", sessionId });
1004
+ }
1005
+ }
1006
+ // A filler clip listed here proves where its uncertain enqueue went.
1007
+ const listedFiller = new Set([...fillers.values()].map((owner) => owner.index));
1008
+ const uncertain = session(sessionId)?.unknownFiller ?? [];
1009
+ if (uncertain.some((entry) => listedFiller.has(entry.index)))
1010
+ updateSession(sessionId, {
1011
+ unknownFiller: uncertain.filter((entry) => !listedFiller.has(entry.index)),
1012
+ });
1013
+ // A filler the provider no longer lists has aired or failed; either way it stops being ours.
1014
+ for (const clipId of fillers.keys())
1015
+ if (!listed.has(clipId))
1016
+ fillers.delete(clipId);
1017
+ updateSession(sessionId, { fillers });
1018
+ // A clip that left every queue without a start we saw may have aired unseen.
1019
+ for (const item of items.values())
1020
+ if (item.sessionId === sessionId &&
1021
+ item.clipId !== undefined &&
1022
+ (item.phase === "Building" || item.phase === "Ready") &&
1023
+ !listed.has(item.clipId))
1024
+ settle(item.spec.key, item.withdraw !== undefined
1025
+ ? { _tag: "Dropped", reason: item.withdraw }
1026
+ : { _tag: "Unobserved" });
1027
+ };
1028
+ /**
1029
+ * After a failed setup that counts, once `maxSetupFailures` do: with no
1030
+ * session holding the air, the playout fails. While `air` holds it, opening
1031
+ * pauses until it no longer does, when one more open is tried once the last
1032
+ * failure's wait is over.
1033
+ */
1034
+ const pauseOrFail = (reason, cause, air) => {
1035
+ if (state.countedFailures < config.maxSetupFailures)
1036
+ return;
1037
+ if (holding(air))
1038
+ state = { ...state, openingPaused: true };
1039
+ else
1040
+ actions.push({ _tag: "Fail", reason, cause });
1041
+ };
1042
+ /**
1043
+ * A session that is gone: its unaired clips are rebuilt from the plan, never
1044
+ * replayed. Reactor ends a session over flagged content, and need not say
1045
+ * so, so rebuilding is bounded twice. A clip lost before it was built with
1046
+ * two sessions in a row fails: a built clip passed screening, so it is
1047
+ * rebuilt however often it is lost, and never fails alongside a flagged one.
1048
+ * And a session lost before any clip sent to it started counts as a failed setup.
1049
+ * Whatever ended it here, it is closed: an owned session still running server-side
1050
+ * would otherwise bill beside its replacement.
1051
+ */
1052
+ const lose = (sessionId, reason, planned = false) => {
1053
+ const lost = session(sessionId);
1054
+ if (lost === undefined)
1055
+ return;
1056
+ actions.push({ _tag: "Close", sessionId });
1057
+ let carried = 0;
1058
+ for (const item of items.values()) {
1059
+ if (item.sessionId !== sessionId || item.phase === "Settled")
1060
+ continue;
1061
+ if (item.phase === "Started")
1062
+ settle(item.spec.key, { _tag: "Failed", reason: { _tag: "Lost", sessionId } });
1063
+ else if (item.phase === "Unknown")
1064
+ settle(item.spec.key, { _tag: "Unknown", terminal: true });
1065
+ else if (item.withdraw !== undefined)
1066
+ settle(item.spec.key, { _tag: "Dropped", reason: item.withdraw });
1067
+ else if (!planned && item.phase === "Building" && item.unbuiltLosses + 1 >= 2)
1068
+ settle(item.spec.key, { _tag: "Failed", reason: { _tag: "Lost", sessionId } });
1069
+ else {
1070
+ carried++;
1071
+ set(item.spec.key, {
1072
+ phase: "Accepted",
1073
+ clipId: undefined,
1074
+ sessionId: undefined,
1075
+ dispatchedAt: undefined,
1076
+ covered: undefined,
1077
+ ...(!planned && item.phase === "Building"
1078
+ ? { unbuiltLosses: item.unbuiltLosses + 1 }
1079
+ : {}),
1080
+ });
1081
+ asRun(item.spec.key, { _tag: "Accepted", carried: { sessionId } });
1082
+ }
1083
+ }
1084
+ // A filler enqueue in flight there made no clip that can still air: it is asked for again.
1085
+ const lostCommand = lost.busy?.command;
1086
+ if (lostCommand?._tag === "Enqueue" && lostCommand.tag._tag === "Filler")
1087
+ retryFiller(lostCommand.tag.index, lostCommand.request);
1088
+ if (!planned && !lost.startedAny && lost.lastEnqueue !== undefined) {
1089
+ const consecutive = state.setupFailures + 1;
1090
+ state = {
1091
+ ...state,
1092
+ setupFailures: consecutive,
1093
+ countedFailures: state.countedFailures + 1,
1094
+ // As after a failed open, the next waits a second longer, and still for a Retry-After.
1095
+ openRetryAt: Math.max(state.openRetryAt, now.mono + setupDelayMs(config, consecutive)),
1096
+ };
1097
+ emit({
1098
+ _tag: "Session",
1099
+ event: {
1100
+ _tag: "SetupFailed",
1101
+ reason: `lost before a clip sent to it started: ${reason}`,
1102
+ consecutive,
1103
+ },
1104
+ });
1105
+ pauseOrFail(reason, "lost", state.air === sessionId ? undefined : session(state.air));
1106
+ }
1107
+ state = {
1108
+ ...state,
1109
+ sessions: state.sessions.filter((value) => value.id !== sessionId),
1110
+ };
1111
+ if (lost.retiring && carried === 0 && state.air !== sessionId)
1112
+ return;
1113
+ emit({ _tag: "Session", event: { _tag: "Replaced", from: sessionId, reason, carried } });
1114
+ if (state.air === sessionId) {
1115
+ const next = preferred() ?? state.sessions[state.sessions.length - 1];
1116
+ state = { ...state, air: next?.id };
1117
+ if (next !== undefined) {
1118
+ updateSession(next.id, { wantAutoplay: true, retiring: false });
1119
+ actions.push({ _tag: "OnAir", sessionId: next.id });
1120
+ }
1121
+ }
1122
+ };
1123
+ const cutFailed = (sessionId) => {
1124
+ if (state.cutting?.sessionId === sessionId)
1125
+ state = { ...state, cutting: { ...state.cutting, stage: "failed" } };
1126
+ };
1127
+ const applyResult = (id, result) => {
1128
+ // The lane it came back on. A result from a session already lost comes too late: the loss
1129
+ // settled or carried what its command was for.
1130
+ const lane = state.sessions.find((value) => value.busy?.id === id);
1131
+ const command = lane?.busy?.command;
1132
+ if (lane === undefined || command === undefined)
1133
+ return;
1134
+ const sessionId = lane.id;
1135
+ updateSession(sessionId, { busy: undefined });
1136
+ switch (command._tag) {
1137
+ case "Enqueue": {
1138
+ if (command.tag._tag === "Filler") {
1139
+ const index = command.tag.index;
1140
+ if (result._tag === "Done") {
1141
+ const clipId = result.clipId;
1142
+ // H3 may list the clip before it replies: one already listed built is not measured.
1143
+ const reported = session(sessionId)?.source;
1144
+ const built = reported !== undefined &&
1145
+ (reported.playing?.clipId === clipId ||
1146
+ reported.ready.some((clip) => clip.clipId === clipId));
1147
+ const sent = lane.fillerSent;
1148
+ const build = sent === undefined || built
1149
+ ? undefined
1150
+ : { dispatchedAt: sent.at, seconds: sent.seconds };
1151
+ if (clipId !== undefined)
1152
+ updateSession(sessionId, {
1153
+ fillers: new Map([...lane.fillers, [clipId, { index, build }]]),
1154
+ });
1155
+ }
1156
+ else if (uncertain(result))
1157
+ // Its clip may be building on that session: it holds that session's build slot until
1158
+ // a queue read shows it, the deadline passes or the session goes. It is never asked
1159
+ // for again, since it may be there.
1160
+ updateSession(sessionId, {
1161
+ unknownFiller: [
1162
+ ...(session(sessionId)?.unknownFiller ?? []),
1163
+ { index, since: now.mono },
1164
+ ],
1165
+ });
1166
+ else {
1167
+ retryFiller(index, command.request);
1168
+ state = { ...state, filler: { ...state.filler, retryAt: now.mono + retryDelayMs } };
1169
+ updateSession(sessionId, { fillerSent: undefined });
1170
+ }
1171
+ return;
1172
+ }
1173
+ const item = items.get(command.tag.key);
1174
+ if (item === undefined || item.phase === "Settled")
1175
+ return;
1176
+ const owner = session(sessionId);
1177
+ if (result._tag === "Done") {
1178
+ set(item.spec.key, { unsent: undefined });
1179
+ if (item.clipId === undefined)
1180
+ set(item.spec.key, { clipId: result.clipId, sessionId });
1181
+ if (item.phase === "Unknown")
1182
+ set(item.spec.key, { phase: "Building", unknownSince: undefined });
1183
+ const now_ = items.get(item.spec.key);
1184
+ if (now_.phase === "Building" && now_.status?._tag !== "Building")
1185
+ asRun(item.spec.key, { _tag: "Building", sessionId });
1186
+ }
1187
+ else if (uncertain(result)) {
1188
+ if (item.clipId === undefined) {
1189
+ set(item.spec.key, {
1190
+ phase: "Unknown",
1191
+ sessionId,
1192
+ everUnknown: true,
1193
+ unknownSince: now.mono,
1194
+ });
1195
+ asRun(item.spec.key, { _tag: "Unknown" });
1196
+ }
1197
+ }
1198
+ else if (item.withdraw !== undefined)
1199
+ // Refused with no clip made: the withdrawal waiting on it has nothing left to remove.
1200
+ settle(item.spec.key, { _tag: "Dropped", reason: item.withdraw });
1201
+ else if (result._tag === "Failed" && result.cause.isRetryable)
1202
+ set(item.spec.key, {
1203
+ phase: "Accepted",
1204
+ sessionId: undefined,
1205
+ dispatchedAt: undefined,
1206
+ retryAt: now.mono + retryDelayMs,
1207
+ });
1208
+ else if (result._tag === "Failed" &&
1209
+ unready(result.cause) &&
1210
+ // Refused again while the session says it takes commands, nothing is left to wait for.
1211
+ !(item.unsent?.sessionId === sessionId && owner?.source?.available === true))
1212
+ set(item.spec.key, {
1213
+ phase: "Accepted",
1214
+ sessionId: undefined,
1215
+ dispatchedAt: undefined,
1216
+ unsent: { sessionId, changes: owner?.changes ?? 0 },
1217
+ });
1218
+ else if (result._tag === "Failed")
1219
+ settle(item.spec.key, {
1220
+ _tag: "Failed",
1221
+ reason: { _tag: "Command", cause: result.cause },
1222
+ });
1223
+ return;
1224
+ }
1225
+ case "Remove": {
1226
+ const owner = [...items.values()].find((item) => item.sessionId === sessionId &&
1227
+ item.clipId === command.clipId &&
1228
+ item.phase !== "Settled");
1229
+ // A removal whose outcome is unknown goes again at once: if it applied, the next is
1230
+ // refused, and the clip is gone. One refused goes again once the queues change.
1231
+ const unknown = result._tag === "Failed" && result.cause.context.outcome === "unknown";
1232
+ if (owner === undefined) {
1233
+ if (result._tag === "Done")
1234
+ return forgetFiller(sessionId, command.clipId);
1235
+ if (unknown)
1236
+ return;
1237
+ const refused = session(sessionId);
1238
+ if (refused === undefined)
1239
+ return;
1240
+ const now_ = signature(refused);
1241
+ const earlier = refused.refusedFiller.signature === now_ ? refused.refusedFiller.clipIds : [];
1242
+ updateSession(sessionId, {
1243
+ refusedFiller: { signature: now_, clipIds: [...earlier, command.clipId] },
1244
+ });
1245
+ return;
1246
+ }
1247
+ if (result._tag === "Done")
1248
+ settle(owner.spec.key, { _tag: "Dropped", reason: owner.withdraw ?? "withdrawn" });
1249
+ else if (!unknown)
1250
+ set(owner.spec.key, { blockedRemove: signature(session(sessionId)) });
1251
+ return;
1252
+ }
1253
+ case "Move":
1254
+ if (result._tag === "Failed")
1255
+ updateSession(sessionId, { blockedMove: signature(session(sessionId)) + command.clipId });
1256
+ // One that died may have applied: a read of the queues will show if it did.
1257
+ else if (result._tag === "Died")
1258
+ updateSession(sessionId, { moveRetryAt: now.mono + retryDelayMs });
1259
+ return;
1260
+ case "Autoplay":
1261
+ if (result._tag === "Done")
1262
+ return updateSession(sessionId, { autoplay: command.enabled });
1263
+ updateSession(sessionId, {
1264
+ // One that may have applied leaves autoplay unknown, so the value wanted goes again.
1265
+ ...(uncertain(result) ? { autoplay: undefined } : {}),
1266
+ autoplayRetry: { enabled: command.enabled, at: now.mono + retryDelayMs },
1267
+ });
1268
+ if (!command.enabled)
1269
+ cutFailed(sessionId);
1270
+ return;
1271
+ // Its clip was marked cut when the cut began; no result makes it cuttable again.
1272
+ // One whose fiber died may not have been sent, so it fails the cut like a refusal.
1273
+ case "Stop":
1274
+ if (result._tag !== "Done")
1275
+ return cutFailed(sessionId);
1276
+ if (state.cutting?.sessionId === sessionId && state.cutting.clipId === command.clipId)
1277
+ state = { ...state, cutting: { ...state.cutting, stage: "stopped" } };
1278
+ return;
1279
+ case "Play":
1280
+ if (result._tag !== "Done")
1281
+ return cutFailed(sessionId);
1282
+ if (state.cutting?.sessionId === sessionId && state.cutting.next === command.clipId)
1283
+ state = { ...state, cutting: { ...state.cutting, stage: "played" } };
1284
+ return;
1285
+ }
1286
+ };
1287
+ switch (input._tag) {
1288
+ case "Edit":
1289
+ applyEdit(input.id, input.edits, input.batch);
1290
+ break;
1291
+ case "Release": {
1292
+ const item = items.get(input.key);
1293
+ if (!live(item) || item.mode !== "held")
1294
+ actions.push({
1295
+ _tag: "Refused",
1296
+ id: input.id,
1297
+ refusal: { _tag: "InvalidItem", key: input.key, message: "no held item has this key" },
1298
+ });
1299
+ else {
1300
+ set(input.key, { mode: "asap" });
1301
+ actions.push({ _tag: "Accepted", id: input.id, results: [] });
1302
+ }
1303
+ break;
1304
+ }
1305
+ case "Drain":
1306
+ state = {
1307
+ ...state,
1308
+ accepting: false,
1309
+ drains: [...state.drains, { id: input.id, finish: input.finish }],
1310
+ };
1311
+ for (const item of items.values())
1312
+ if (live(item) &&
1313
+ (item.mode === "held" || (input.finish === "playing" && item.phase !== "Started")))
1314
+ withdraw(item.spec.key, "withdrawn");
1315
+ break;
1316
+ case "Opened": {
1317
+ const first = state.air === undefined;
1318
+ state = {
1319
+ ...state,
1320
+ opening: false,
1321
+ air: first ? input.sessionId : state.air,
1322
+ sessions: [
1323
+ ...state.sessions,
1324
+ {
1325
+ id: input.sessionId,
1326
+ openedAt: now.mono,
1327
+ lifetimeMs: input.lifetimeMs,
1328
+ source: undefined,
1329
+ changes: 0,
1330
+ autoplay: undefined,
1331
+ wantAutoplay: first,
1332
+ autoplayRetry: undefined,
1333
+ retiring: false,
1334
+ lastEndedAt: undefined,
1335
+ startedAny: false,
1336
+ indeterminate: false,
1337
+ unknownFiller: [],
1338
+ playing: undefined,
1339
+ lastEnqueue: undefined,
1340
+ refusedFiller: { signature: "", clipIds: [] },
1341
+ blockedMove: undefined,
1342
+ moveRetryAt: 0,
1343
+ busy: undefined,
1344
+ fillerSent: undefined,
1345
+ fillers: new Map(),
1346
+ },
1347
+ ],
1348
+ };
1349
+ // The session on air before stays until the replacement can take over at a boundary.
1350
+ if (!first)
1351
+ for (const value of state.sessions)
1352
+ if (value.id !== input.sessionId)
1353
+ updateSession(value.id, { retiring: true });
1354
+ if (first)
1355
+ actions.push({ _tag: "OnAir", sessionId: input.sessionId });
1356
+ emit({
1357
+ _tag: "Session",
1358
+ event: {
1359
+ _tag: "Opened",
1360
+ sessionId: input.sessionId,
1361
+ ...(Number.isFinite(input.lifetimeMs)
1362
+ ? { lifetimeSeconds: input.lifetimeMs / 1000 }
1363
+ : {}),
1364
+ },
1365
+ });
1366
+ break;
1367
+ }
1368
+ case "OpenFailed": {
1369
+ const consecutive = state.setupFailures + 1;
1370
+ // A refusal that allocated nothing billed nothing: refused while a session holds the air, it
1371
+ // counts toward no limit, then or later, though the next open waits longer.
1372
+ const counted = input.allocated || !holding(session(state.air));
1373
+ state = {
1374
+ ...state,
1375
+ opening: false,
1376
+ setupFailures: consecutive,
1377
+ countedFailures: state.countedFailures + (counted ? 1 : 0),
1378
+ // A refusal that says when to ask again is not asked sooner.
1379
+ openRetryAt: now.mono + Math.max(setupDelayMs(config, consecutive), input.retryAfterMs ?? 0),
1380
+ };
1381
+ emit({ _tag: "Session", event: { _tag: "SetupFailed", reason: input.reason, consecutive } });
1382
+ if (input.fatal)
1383
+ actions.push({ _tag: "Fail", reason: input.reason, cause: "open" });
1384
+ // One that doesn't count is asked again after its delay, even once those that do ran out.
1385
+ else if (counted)
1386
+ pauseOrFail(input.reason, "open", session(state.air));
1387
+ break;
1388
+ }
1389
+ case "Source": {
1390
+ if (session(input.sessionId) === undefined)
1391
+ break;
1392
+ const event = input.event;
1393
+ switch (event._tag) {
1394
+ case "State":
1395
+ observe(input.sessionId, event.state);
1396
+ break;
1397
+ case "Started":
1398
+ started(input.sessionId, event.clip);
1399
+ break;
1400
+ case "Ended":
1401
+ ended(input.sessionId, event);
1402
+ break;
1403
+ case "Failed":
1404
+ failed(input.sessionId, event);
1405
+ break;
1406
+ case "Moderated":
1407
+ moderated(input.sessionId, event);
1408
+ break;
1409
+ case "Reconnecting": {
1410
+ // It takes no commands until its source reports a state again.
1411
+ const source = session(input.sessionId)?.source;
1412
+ if (source !== undefined)
1413
+ report(input.sessionId, { ...source, available: false });
1414
+ emit({ _tag: "Session", event: { _tag: "Reconnecting", sessionId: input.sessionId } });
1415
+ break;
1416
+ }
1417
+ case "Reconnected":
1418
+ emit({
1419
+ _tag: "Session",
1420
+ event: {
1421
+ _tag: "Reconnected",
1422
+ sessionId: input.sessionId,
1423
+ afterMillis: event.afterMillis,
1424
+ },
1425
+ });
1426
+ break;
1427
+ case "ReaderOverflow":
1428
+ emit({
1429
+ _tag: "ReaderOverflow",
1430
+ sessionId: input.sessionId,
1431
+ track: event.track,
1432
+ at: now.wall,
1433
+ pressure: event.pressure,
1434
+ });
1435
+ break;
1436
+ }
1437
+ break;
1438
+ }
1439
+ case "Lost":
1440
+ lose(input.sessionId, input.reason);
1441
+ break;
1442
+ case "Result":
1443
+ applyResult(input.id, input.result);
1444
+ break;
1445
+ case "Tick":
1446
+ break;
1447
+ case "Close": {
1448
+ state = { ...state, accepting: false, closed: true };
1449
+ // A pending batch's withdrawals take effect now: what they name never airs, and each
1450
+ // is answered with what became of its item (#64).
1451
+ for (const batch of state.batches)
1452
+ for (const target of batch.targets)
1453
+ withdraw(target.key, target.reason, target.index >= 0 ? { id: batch.id, index: target.index } : undefined);
1454
+ for (const item of items.values()) {
1455
+ if (item.phase === "Settled")
1456
+ continue;
1457
+ if (item.phase === "Unknown")
1458
+ settle(item.spec.key, { _tag: "Unknown", terminal: true });
1459
+ else if (item.phase === "Started")
1460
+ settle(item.spec.key, { _tag: "Unobserved" });
1461
+ else if (item.withdraw !== undefined)
1462
+ settle(item.spec.key, { _tag: "Dropped", reason: item.withdraw });
1463
+ else
1464
+ settle(item.spec.key, { _tag: "Failed", reason: { _tag: "Closed" } });
1465
+ }
1466
+ for (const batch of state.batches)
1467
+ actions.push({ _tag: "Refused", id: batch.id, refusal: { _tag: "PlayoutClosed" } });
1468
+ for (const drain of state.drains)
1469
+ actions.push({ _tag: "Drained", id: drain.id });
1470
+ state = { ...state, batches: [], drains: [] };
1471
+ break;
1472
+ }
1473
+ }
1474
+ if (state.closed)
1475
+ return { state: { ...state, items }, actions, wake: undefined };
1476
+ // Sweep every waiting item, not only the heads: expiry must not strand behind a live one. Each
1477
+ // is late from the instant its deadline falls due, where the wake is.
1478
+ const boundaryLate = (item) => {
1479
+ const at = atMono(item);
1480
+ if (at === undefined || item.spec.start._tag !== "At")
1481
+ return false;
1482
+ const late = item.spec.start.late;
1483
+ return late === "nextBoundary"
1484
+ ? false
1485
+ : now.mono >= (late === "drop" ? at : at + late.skipAfterMs);
1486
+ };
1487
+ for (const item of [...items.values()]) {
1488
+ if (item.phase !== "Accepted" && item.phase !== "Building" && item.phase !== "Ready")
1489
+ continue;
1490
+ if ((item.spec.window?.firm === true && item.startBy !== undefined && now.mono >= item.startBy) ||
1491
+ boundaryLate(item))
1492
+ withdraw(item.spec.key, "late");
1493
+ else if (lateWhenProjected(item) && misses(item, item.startBy))
1494
+ withdraw(item.spec.key, "late");
1495
+ }
1496
+ // A replacement takes the place once Ready, or at once if nothing was built for what it
1497
+ // replaces; if what it replaces starts first, the replacement goes instead.
1498
+ for (const item of [...items.values()]) {
1499
+ if (item.replaces === undefined || item.phase === "Settled")
1500
+ continue;
1501
+ const old = replacedOf(item);
1502
+ if (old === undefined || old.phase === "Settled")
1503
+ continue;
1504
+ if (old.phase === "Started") {
1505
+ if (item.phase !== "Started")
1506
+ withdraw(item.spec.key, "withdrawn");
1507
+ }
1508
+ else if ((item.phase === "Ready" && item.batch === undefined) ||
1509
+ item.phase === "Started" ||
1510
+ (old.phase === "Accepted" && old.dispatchedAt === undefined))
1511
+ withdraw(old.spec.key, "replaced");
1512
+ }
1513
+ // A batch takes effect once everything it adds is Ready or has settled.
1514
+ for (const batch of state.batches) {
1515
+ const done = batch.adds.every((key) => {
1516
+ const phase = items.get(key)?.phase;
1517
+ return phase === undefined || phase === "Ready" || phase === "Started" || phase === "Settled";
1518
+ });
1519
+ if (!done)
1520
+ continue;
1521
+ state = { ...state, batches: state.batches.filter((value) => value.id !== batch.id) };
1522
+ for (const key of batch.adds)
1523
+ if (items.has(key))
1524
+ set(key, { batch: undefined });
1525
+ for (const target of batch.targets)
1526
+ withdraw(target.key, target.reason, target.index >= 0 ? { id: batch.id, index: target.index } : undefined);
1527
+ actions.push({ _tag: "Committed", id: batch.id });
1528
+ }
1529
+ // An enqueue whose outcome stays unknown past the deadline makes its session indeterminate:
1530
+ // it takes no new work and a replacement takes over, now rather than at the next input. One
1531
+ // not on air has nothing to finish, so it goes at once.
1532
+ const expired = (since) => since !== undefined && now.mono >= since + config.unknownTimeoutMs;
1533
+ for (const value of [...state.sessions]) {
1534
+ if (value.indeterminate)
1535
+ continue;
1536
+ const stuck = value.unknownFiller.some((entry) => expired(entry.since)) ||
1537
+ [...items.values()].some((item) => item.phase === "Unknown" && item.sessionId === value.id && expired(item.unknownSince));
1538
+ if (!stuck)
1539
+ continue;
1540
+ updateSession(value.id, { indeterminate: true });
1541
+ if (value.id !== state.air)
1542
+ lose(value.id, "an enqueue's outcome stayed unknown");
1543
+ }
1544
+ // Renewal.
1545
+ const expiresAt = (value) => value.openedAt + value.lifetimeMs;
1546
+ for (const value of state.sessions)
1547
+ if (now.mono >= expiresAt(value) && value.lifetimeMs !== Infinity)
1548
+ lose(value.id, "the session's granted length ended");
1549
+ // Setups ran out while a session held the air; once none does, one more open is tried, once
1550
+ // the last failure's wait is over.
1551
+ if (state.openingPaused && !holding(session(state.air)))
1552
+ state = { ...state, openingPaused: false };
1553
+ const air = session(state.air);
1554
+ const wantsAir = state.accepting ||
1555
+ state.drains.some((drain) => drain.finish === "accepted") ||
1556
+ [...items.values()].some(live);
1557
+ if (!state.opening && !state.openingPaused && now.mono >= state.openRetryAt && wantsAir) {
1558
+ const replacementLive = state.sessions.some((value) => !value.retiring && value.id !== state.air);
1559
+ // Only the lead opens a replacement. What doesn't fit before the cap waits for it: the
1560
+ // session on air then holds air nearly to its cap, and a replacement opened earlier would
1561
+ // bill while it waits to air.
1562
+ const due = air === undefined ||
1563
+ (!replacementLive && (air.indeterminate || now.mono >= expiresAt(air) - config.leadMs));
1564
+ if (due && state.sessions.length < 2) {
1565
+ state = { ...state, opening: true };
1566
+ actions.push({ _tag: "Open" });
1567
+ }
1568
+ }
1569
+ const current = session(state.air);
1570
+ const next = state.sessions.find((value) => value.id !== state.air && !value.retiring);
1571
+ if (current !== undefined && next !== undefined && next.source?.available === true) {
1572
+ // The drain rule: once the replacement has an item Ready, the retiring filler goes.
1573
+ if (readyOf(next).some((clip) => clip.tag?._tag === "Item"))
1574
+ for (const clip of readyOf(current))
1575
+ if (fillerRemovable(current, clip))
1576
+ queueCommand(current.id, { _tag: "Remove", clipId: clip.clipId });
1577
+ const idle = current.source !== undefined &&
1578
+ current.source.playing === undefined &&
1579
+ current.source.ready.length === 0 &&
1580
+ current.source.building.every((clip) => clip.tag === undefined) &&
1581
+ ![...items.values()].some((item) => item.sessionId === current.id &&
1582
+ (item.phase === "Building" || (item.phase === "Unknown" && !expired(item.unknownSince)))) &&
1583
+ current.unknownFiller.every((entry) => expired(entry.since));
1584
+ const graceOver = current.lastEndedAt !== undefined && now.mono >= current.lastEndedAt + config.graceMs;
1585
+ if (idle && (!current.startedAny || graceOver)) {
1586
+ state = { ...state, air: next.id };
1587
+ updateSession(next.id, { wantAutoplay: true });
1588
+ actions.push({ _tag: "OnAir", sessionId: next.id });
1589
+ emit({
1590
+ _tag: "Session",
1591
+ event: {
1592
+ _tag: "Switched",
1593
+ from: current.id,
1594
+ to: next.id,
1595
+ decision: current.startedAny ? "grace-elapsed" : "no-observed-start",
1596
+ },
1597
+ });
1598
+ lose(current.id, "retired", true);
1599
+ }
1600
+ }
1601
+ // Starvation: nothing on air while the plan wants air.
1602
+ const onAir = session(state.air);
1603
+ const taking = state.sessions.some((value) => value.id !== state.air && !value.retiring && readyOf(value).length > 0);
1604
+ const dry = onAir?.source !== undefined &&
1605
+ onAir.source.playing === undefined &&
1606
+ onAir.source.ready.length === 0 &&
1607
+ onAir.startedAny &&
1608
+ !taking;
1609
+ const owed = [...items.values()].some((item) => item.phase !== "Settled" && item.mode !== "held") ||
1610
+ config.filler !== undefined;
1611
+ if (dry && owed && !state.starving) {
1612
+ state = { ...state, starving: true, starved: state.starved + 1 };
1613
+ emit({ _tag: "Starved", at: now.wall });
1614
+ }
1615
+ else if (!dry && state.starving)
1616
+ state = { ...state, starving: false };
1617
+ const fireCue = (item, index) => emit({
1618
+ _tag: "Cue",
1619
+ event: { key: item.spec.key, name: item.spec.cues[index].name, at: now.wall },
1620
+ });
1621
+ // Cues of the playing item fall due on its observed start.
1622
+ for (const item of [...items.values()]) {
1623
+ if (item.phase !== "Started" || item.startedAt === undefined || item.spec.cues.length === 0)
1624
+ continue;
1625
+ const due = item.spec.cues
1626
+ .map((cue, index) => ({ index, at: cueAt(item, cue) }))
1627
+ .filter(({ index, at }) => !item.fired.includes(index) && at <= now.mono)
1628
+ .sort((a, b) => a.at - b.at);
1629
+ for (const { index } of due)
1630
+ fireCue(item, index);
1631
+ if (due.length > 0)
1632
+ set(item.spec.key, { fired: [...item.fired, ...due.map(({ index }) => index)] });
1633
+ }
1634
+ // A playing clip's cues after its end fire at the end, if it ended there.
1635
+ for (const item of [...items.values()])
1636
+ if (item.phase === "Settled" &&
1637
+ item.status?._tag === "Ended" &&
1638
+ item.startedAt !== undefined &&
1639
+ item.fired.length < item.spec.cues.length) {
1640
+ const fire = item.spec.cues
1641
+ .map((_, index) => index)
1642
+ .filter((index) => !item.fired.includes(index) && cueAt(item, item.spec.cues[index]) <= now.mono + 1);
1643
+ for (const index of fire)
1644
+ fireCue(item, index);
1645
+ set(item.spec.key, { fired: item.spec.cues.map((_, index) => index) });
1646
+ }
1647
+ // Drains finish once nothing they wait for is left.
1648
+ for (const drain of state.drains) {
1649
+ const pending = [...items.values()].some((item) => live(item) && (drain.finish === "accepted" || item.phase === "Started"));
1650
+ if (!pending) {
1651
+ actions.push({ _tag: "Drained", id: drain.id });
1652
+ state = { ...state, drains: state.drains.filter((value) => value.id !== drain.id) };
1653
+ }
1654
+ }
1655
+ // Each session's commands go one at a time on a lane of its own.
1656
+ endCut();
1657
+ for (const value of state.sessions)
1658
+ decideCommand(value.id);
1659
+ // History: settled keys past the bound are forgotten, oldest first.
1660
+ if (state.settled.length > config.maxHistory) {
1661
+ const drop = state.settled.slice(0, state.settled.length - config.maxHistory);
1662
+ for (const key of drop)
1663
+ items.delete(key);
1664
+ actions.push({ _tag: "Forget", keys: drop });
1665
+ for (const [group, value] of groups)
1666
+ if (value.parts.every((part) => !items.has(part)))
1667
+ groups.delete(group);
1668
+ state = { ...state, settled: state.settled.slice(drop.length) };
1669
+ }
1670
+ return { state: { ...state, items, groups }, actions, wake: wake() };
1671
+ function queueCommand(sessionId, command) {
1672
+ const lane = session(sessionId);
1673
+ if (lane === undefined || lane.busy !== undefined)
1674
+ return;
1675
+ const id = state.nextCommand;
1676
+ state = { ...state, nextCommand: id + 1 };
1677
+ updateSession(sessionId, {
1678
+ busy: { id, command },
1679
+ ...(command._tag === "Enqueue" ? { lastEnqueue: command.tag } : {}),
1680
+ });
1681
+ actions.push({ _tag: "Command", id, sessionId, command });
1682
+ }
1683
+ /** A filler clip a session takes commands for, unless its removal was refused as things stand. */
1684
+ function fillerRemovable(value, clip) {
1685
+ return (clip.tag?._tag === "Filler" &&
1686
+ value.source?.available === true &&
1687
+ !(value.refusedFiller.signature === signature(value) &&
1688
+ value.refusedFiller.clipIds.includes(clip.clipId)));
1689
+ }
1690
+ /** A session's queues as they stand: a clip finishing its build changes them too. */
1691
+ function signature(value) {
1692
+ const source = value?.source;
1693
+ return source === undefined
1694
+ ? ""
1695
+ : [source.building, source.ready, source.playing === undefined ? [] : [source.playing]]
1696
+ .map((clips) => clips.map((clip) => clip.clipId).join(","))
1697
+ .join(":");
1698
+ }
1699
+ function cueAt(item, cue) {
1700
+ return cue.from === "start"
1701
+ ? item.startedAt + cue.offsetMs
1702
+ : item.startedAt + (item.airSeconds ?? item.spec.seconds) * 1000 - cue.offsetMs;
1703
+ }
1704
+ /** The cut's cutter while it waits Ready on its session, and whether the plan still wants it. */
1705
+ function cutterOf() {
1706
+ const cutting = state.cutting;
1707
+ const cutter = session(cutting?.sessionId)?.source?.ready.find((clip) => clip.clipId === cutting?.next);
1708
+ return {
1709
+ cutter,
1710
+ wanted: cutter !== undefined && airs(cutter) && itemOf(cutter)?.phase === "Ready",
1711
+ };
1712
+ }
1713
+ /**
1714
+ * A cut ends once its cutter has left its session's Ready queue; once a step
1715
+ * failed with the cutter still wanted, which then airs at the next boundary;
1716
+ * or once a cutter no longer wanted is not being removed.
1717
+ */
1718
+ function endCut() {
1719
+ const cutting = state.cutting;
1720
+ if (cutting === undefined)
1721
+ return;
1722
+ const { cutter, wanted } = cutterOf();
1723
+ const cutterItem = itemOf(cutter);
1724
+ const removing = cutterItem?.withdraw !== undefined &&
1725
+ cutterItem.blockedRemove !== signature(session(cutting.sessionId));
1726
+ if (cutter === undefined || (cutting.stage === "failed" && wanted) || (!wanted && !removing))
1727
+ state = { ...state, cutting: undefined };
1728
+ }
1729
+ /** The next command for a session whose lane is free, if the plan wants one there. */
1730
+ function decideCommand(sessionId) {
1731
+ const value = session(sessionId);
1732
+ if (value === undefined || value.busy !== undefined)
1733
+ return;
1734
+ // Autoplay as its role wants it: off on a replacement until it takes the air, and off on the
1735
+ // air while a cut is under way.
1736
+ const autoplay = value.wantAutoplay && state.cutting?.sessionId !== value.id;
1737
+ if (value.source?.available === true && value.autoplay !== autoplay) {
1738
+ // Nothing else goes before it, even while a failed one waits to be asked again.
1739
+ const retry = value.autoplayRetry;
1740
+ if (retry === undefined || retry.enabled !== autoplay || now.mono >= retry.at)
1741
+ queueCommand(value.id, { _tag: "Autoplay", enabled: autoplay });
1742
+ return;
1743
+ }
1744
+ // The cut's next step, with autoplay off: stop the clip it cuts, and once that has ended,
1745
+ // play the cutter. The plan looks again between them, so a cutter withdrawn meanwhile goes
1746
+ // instead, and until its play nothing else is sent that could hold it up.
1747
+ const cutting = state.cutting;
1748
+ if (cutting?.sessionId === value.id &&
1749
+ (cutting.stage === "stopping" || cutting.stage === "stopped") &&
1750
+ cutterOf().wanted &&
1751
+ value.source?.available === true) {
1752
+ const playing = value.source.playing?.clipId;
1753
+ if (cutting.stage === "stopping")
1754
+ return queueCommand(value.id, { _tag: "Stop", clipId: cutting.clipId });
1755
+ if (playing === undefined)
1756
+ return queueCommand(value.id, { _tag: "Play", clipId: cutting.next });
1757
+ if (playing === cutting.clipId)
1758
+ return;
1759
+ // Something else plays: the cutter waits for the next boundary.
1760
+ state = { ...state, cutting: undefined };
1761
+ return decideCommand(sessionId);
1762
+ }
1763
+ // Withdrawals the plan wants, retried once a refused one's session changes.
1764
+ for (const item of items.values())
1765
+ if (item.withdraw !== undefined &&
1766
+ item.clipId !== undefined &&
1767
+ item.sessionId === value.id &&
1768
+ (item.phase === "Building" || item.phase === "Ready") &&
1769
+ value.source?.available === true &&
1770
+ item.blockedRemove !== signature(value))
1771
+ return queueCommand(value.id, { _tag: "Remove", clipId: item.clipId });
1772
+ // A drain withdraws filler once nothing accepted still needs it to cover the wait.
1773
+ const fillerNeeded = state.drains.every((drain) => drain.finish === "accepted") &&
1774
+ [...items.values()].some((item) => live(item) &&
1775
+ item.mode !== "held" &&
1776
+ item.phase !== "Started" &&
1777
+ (item.phase !== "Ready" || (atMono(item) ?? -Infinity) > now.mono));
1778
+ if (state.drains.length > 0 && !fillerNeeded)
1779
+ for (const clip of readyOf(value))
1780
+ if (fillerRemovable(value, clip))
1781
+ return queueCommand(value.id, { _tag: "Remove", clipId: clip.clipId });
1782
+ // Order its Ready clips by rank. A move never ranks across sessions.
1783
+ const actual = readyOf(value);
1784
+ const desired = [...actual].sort((a, b) => compareRank(rankClip(a), rankClip(b)));
1785
+ const moved = actual.findIndex((clip, index) => clip.clipId !== desired[index].clipId);
1786
+ const misplaced = moved < 0 ? undefined : desired[moved];
1787
+ if (value.source?.available === true &&
1788
+ misplaced?.tag !== undefined &&
1789
+ value.blockedMove !== signature(value) + misplaced.clipId &&
1790
+ now.mono >= value.moveRetryAt)
1791
+ return queueCommand(value.id, { _tag: "Move", clipId: misplaced.clipId, position: moved });
1792
+ // A cut lane's Ready item at the front cuts a lower lane's clip, or filler, that has a while to run.
1793
+ const front = value.id === state.air ? actual[0] : undefined;
1794
+ const cutItem = itemOf(front);
1795
+ const playing = value.source?.playing;
1796
+ if (front !== undefined &&
1797
+ cutItem?.phase === "Ready" &&
1798
+ playing !== undefined &&
1799
+ state.cutting === undefined &&
1800
+ config.lanes[cutItem.spec.lane]?.cut === true &&
1801
+ !(state.cut?.sessionId === value.id && state.cut.clipId === playing.clipId) &&
1802
+ playingRestMs(value) > cutMarginMs) {
1803
+ // Only filler or a clip of a strictly lower lane is cut, never one of the cutter's lane or above.
1804
+ const playingItem = itemOf(playing);
1805
+ const lower = playing.tag?._tag === "Filler" ||
1806
+ (playingItem !== undefined && playingItem.spec.lane > cutItem.spec.lane);
1807
+ if (lower && airs(front)) {
1808
+ state = {
1809
+ ...state,
1810
+ cut: { sessionId: value.id, clipId: playing.clipId },
1811
+ cutting: {
1812
+ sessionId: value.id,
1813
+ clipId: playing.clipId,
1814
+ next: front.clipId,
1815
+ stage: "stopping",
1816
+ },
1817
+ };
1818
+ return decideCommand(sessionId);
1819
+ }
1820
+ }
1821
+ // A held item about to be next, or an At item Ready too early, is removed: rebuilt later, or
1822
+ // dropped if withdrawn.
1823
+ for (const [index, clip] of actual.entries()) {
1824
+ const item = itemOf(clip);
1825
+ if (item?.phase !== "Ready")
1826
+ continue;
1827
+ const readyMs = readyAheadMs(value, index);
1828
+ const aheadMs = playingRestMs(value) + readyMs;
1829
+ const at = atMono(item);
1830
+ const exposed = item.mode === "held"
1831
+ ? aheadMs < exposureMarginMs || now.mono >= (exposedAt(value, readyMs) ?? Infinity)
1832
+ : at !== undefined && at > now.mono && aheadMs < at - now.mono;
1833
+ if (exposed && value.source?.available === true) {
1834
+ // A withdrawal waiting on its session's queues to change is due now: its removal is asked
1835
+ // again, once, and drops the item. Refused again, it waits for a change after all.
1836
+ if (item.withdraw !== undefined) {
1837
+ if (item.discarded?.sessionId === value.id && item.discarded.clipId === clip.clipId)
1838
+ continue;
1839
+ set(item.spec.key, {
1840
+ blockedRemove: undefined,
1841
+ discarded: { sessionId: value.id, clipId: clip.clipId },
1842
+ });
1843
+ return queueCommand(value.id, { _tag: "Remove", clipId: clip.clipId });
1844
+ }
1845
+ // It goes back to the plan, to be built again once the air ahead covers its wait.
1846
+ set(item.spec.key, {
1847
+ phase: "Accepted",
1848
+ dispatchedAt: undefined,
1849
+ clipId: undefined,
1850
+ sessionId: undefined,
1851
+ covered: undefined,
1852
+ discarded: { sessionId: value.id, clipId: clip.clipId },
1853
+ });
1854
+ return queueCommand(value.id, { _tag: "Remove", clipId: clip.clipId });
1855
+ }
1856
+ }
1857
+ // Build, on the session that takes new work: the first eligible item by build order, else
1858
+ // filler below its floor.
1859
+ const target = preferred();
1860
+ if (target?.id !== value.id ||
1861
+ !(state.accepting || state.drains.some((drain) => drain.finish === "accepted")))
1862
+ return;
1863
+ // An enqueue whose reply was lost holds no build slot: H3 builds in order, so if it landed it
1864
+ // builds ahead of what follows, and if it did not, nothing would free the slot but the
1865
+ // unknown timeout. The cap check still counts it, since it may yet take air here.
1866
+ const inFlight = [...items.values()].filter((item) => item.phase === "Building" && item.sessionId === target.id).length + target.source.building.filter((clip) => clip.tag?._tag === "Filler").length;
1867
+ if (inFlight >= config.maxBuildsInFlight)
1868
+ return;
1869
+ const room = runway();
1870
+ const floorSeconds = fillerFloor();
1871
+ const drainingNeeds = state.drains.length === 0 || fillerNeeded;
1872
+ for (const item of eligible(floorSeconds)) {
1873
+ // What cannot air before this session's cap waits for its replacement, and so does what
1874
+ // follows it, which would otherwise air ahead of it.
1875
+ if (!fits(target, item.spec.seconds))
1876
+ break;
1877
+ const from = item.spec.continuity
1878
+ ? continuation(item, target)
1879
+ : { _tag: "from", clipId: undefined, follows: undefined };
1880
+ if (from._tag === "wait")
1881
+ continue;
1882
+ if (drainingNeeds && coverFirst(target, item, from.clipId !== undefined, room))
1883
+ return;
1884
+ set(item.spec.key, {
1885
+ phase: "Building",
1886
+ sessionId: target.id,
1887
+ dispatchedAt: now.mono,
1888
+ retryAt: undefined,
1889
+ continued: from.clipId !== undefined,
1890
+ follows: from.follows,
1891
+ });
1892
+ return queueCommand(target.id, {
1893
+ _tag: "Enqueue",
1894
+ request: item.spec.request,
1895
+ tag: { _tag: "Item", key: item.spec.key },
1896
+ continueFrom: from.clipId,
1897
+ });
1898
+ }
1899
+ const filler = config.filler;
1900
+ if (filler === undefined || !fillerFree())
1901
+ return;
1902
+ const anchorGap = Math.max(0, ...[...items.values()].flatMap((item) => {
1903
+ const at = atMono(item);
1904
+ return live(item) && at !== undefined && at > now.mono ? [(at - now.mono) / 1000] : [];
1905
+ }));
1906
+ const targetSeconds = Math.max(filler.target, floorSeconds);
1907
+ const refilling = anchorGap > room || (state.filler.refilling ? below(targetSeconds) : below(floorSeconds));
1908
+ state = { ...state, filler: { ...state.filler, refilling } };
1909
+ if (!refilling || (!below(targetSeconds) && room >= anchorGap) || !drainingNeeds)
1910
+ return;
1911
+ const seconds = fillLength(anchorGap - room, filler.lengths, estimates().length);
1912
+ if (!fits(target, fillerLength(seconds)))
1913
+ return;
1914
+ sendFiller(filler, target, seconds, room);
1915
+ }
1916
+ /**
1917
+ * `item`'s p95 build in seconds, at the continued rate if it continues a
1918
+ * clip. Unknown until three builds were measured.
1919
+ */
1920
+ function buildOf(item, continued) {
1921
+ const p95 = estimates().build?.p95;
1922
+ if (p95 === undefined)
1923
+ return undefined;
1924
+ return (continued ? (continuedBuildRate(state.samples) ?? p95) : p95) * item.spec.seconds;
1925
+ }
1926
+ /**
1927
+ * Protecting the air, sends a filler clip ahead of `item` when its p95 build
1928
+ * would outlast the air secured. The clip is long enough, within the filler's
1929
+ * lengths, that the air it adds less what its own build drains covers the
1930
+ * rest and a margin, but airs no longer than the item builds, which a longer
1931
+ * clip would only delay, unless the shortest the filler takes is longer. An
1932
+ * item waits for one such clip at most, and for none that cannot be sent
1933
+ * now, would add no air or build no sooner than the item, or would not air
1934
+ * before the session's cap. A shortfall within the margin alone is left to
1935
+ * the floor, which refills without holding the item.
1936
+ */
1937
+ function coverFirst(target, item, continued, room) {
1938
+ const filler = config.filler;
1939
+ if (filler === undefined || !protects(item) || item.covered === true || !fillerFree())
1940
+ return false;
1941
+ const build = buildOf(item, continued);
1942
+ const rate = estimates().build?.p95;
1943
+ if (build === undefined || rate === undefined)
1944
+ return false;
1945
+ const dark = build - securedOf({ ...state, items }, now);
1946
+ const short = dark + lookaheadMarginSeconds;
1947
+ // A requested second airs as long as asked, or as a provider that cuts clips short leaves it.
1948
+ const airs = Math.min(1, estimates().length);
1949
+ if (dark <= 0 || airs <= rate)
1950
+ return false;
1951
+ // Sized as if the clip's build starts now and the item's once it ends, as with one build in
1952
+ // flight, the default. With more, a build already in flight here goes ahead of both, and
1953
+ // neither the air it drains meanwhile nor the air it adds is counted.
1954
+ const seconds = Math.min(filler.lengths.max, Math.max(filler.lengths.min, Math.min(short / (airs - rate), build / airs)));
1955
+ // A refused clip goes again as it was asked for: it must build sooner and fit at its length.
1956
+ const length = fillerLength(seconds);
1957
+ if (rate * length >= build || !fits(target, length))
1958
+ return false;
1959
+ set(item.spec.key, { covered: true });
1960
+ sendFiller(filler, target, seconds, room);
1961
+ return true;
1962
+ }
1963
+ /**
1964
+ * Whether filler protects the air ahead of `item`'s build. What has a time to
1965
+ * meet goes as soon as it may: filler ahead would only make it later, and a
1966
+ * firm one late enough to be dropped. An `At` start or a `startBy` names that
1967
+ * time; an `Asap` start, a released `Manual` item, which airs as one, or a
1968
+ * lane that cuts makes it now.
1969
+ */
1970
+ function protects(item) {
1971
+ return (config.filler?.protect === "air" &&
1972
+ item.startBy === undefined &&
1973
+ item.spec.start._tag !== "At" &&
1974
+ item.mode !== "asap" &&
1975
+ config.lanes[item.spec.lane]?.cut !== true);
1976
+ }
1977
+ /**
1978
+ * Whether a filler clip may be sent: none waits out a refused one. Each clip takes its index
1979
+ * as its enqueue goes out, so each lane may carry one, and none waits for another's.
1980
+ */
1981
+ function fillerFree() {
1982
+ return now.mono >= state.filler.retryAt;
1983
+ }
1984
+ /** Asks for the filler clip `index` again as it was, unless moderation flagged it. */
1985
+ function retryFiller(index, request) {
1986
+ if (state.filler.flagged.includes(index))
1987
+ return;
1988
+ const retries = [...state.filler.retries, { index, request }].sort((a, b) => a.index - b.index);
1989
+ state = { ...state, filler: { ...state.filler, retries } };
1990
+ }
1991
+ /**
1992
+ * Seconds the next filler clip asks for: a refused one is asked for again as it was, at its own
1993
+ * length, and a new one for `seconds`.
1994
+ */
1995
+ function fillerLength(seconds) {
1996
+ const request = state.filler.retries[0]?.request;
1997
+ return request === undefined ? seconds : (request.seconds ?? requestSeconds.min);
1998
+ }
1999
+ /** Sends the next filler clip to `target`, a new one asked for `seconds`. */
2000
+ function sendFiller(filler, target, seconds, room) {
2001
+ const [retry, ...retries] = state.filler.retries;
2002
+ const index = retry?.index ?? state.filler.index;
2003
+ const request = retry?.request ?? filler.clip({ index, runwaySeconds: room, seconds });
2004
+ const invalid = filler.invalid(request, index);
2005
+ if (invalid !== undefined) {
2006
+ // Skipping it would leave the air uncovered without a word, and asking again gets the same.
2007
+ state = { ...state, filler: { ...state.filler, retryAt: Infinity } };
2008
+ actions.push({ _tag: "Fail", reason: invalid, cause: "filler", index });
2009
+ return;
2010
+ }
2011
+ state = {
2012
+ ...state,
2013
+ filler: {
2014
+ ...state.filler,
2015
+ ...(retry === undefined ? { index: index + 1 } : { retries }),
2016
+ },
2017
+ };
2018
+ updateSession(target.id, {
2019
+ fillerSent: { at: now.mono, seconds: request.seconds ?? requestSeconds.min },
2020
+ });
2021
+ queueCommand(target.id, { _tag: "Enqueue", request, tag: { _tag: "Filler", index } });
2022
+ }
2023
+ /** Items that may be built now, first in build order. */
2024
+ function eligible(floorSeconds) {
2025
+ const target = preferred();
2026
+ return [...items.values()]
2027
+ .filter((item) => item.phase === "Accepted" &&
2028
+ item.withdraw === undefined &&
2029
+ previousAdmitted(item) &&
2030
+ (item.retryAt ?? -Infinity) <= now.mono &&
2031
+ // A session that refused it unsent gets it again only once what it reports has changed.
2032
+ (item.unsent === undefined ||
2033
+ item.unsent.sessionId !== target?.id ||
2034
+ item.unsent.changes !== target.changes) &&
2035
+ (item.notBefore ?? -Infinity) <= now.mono &&
2036
+ // Autoplay cannot hold a Ready clip: build a future anchor once air ahead covers the wait.
2037
+ covered(item) &&
2038
+ // A held item is built ahead only while filler keeps the runway at its floor.
2039
+ (item.mode !== "held" || (floorSeconds > 0 && !below(floorSeconds))))
2040
+ .sort(buildOrder);
2041
+ }
2042
+ /**
2043
+ * Whether the air secured lasts to an `At` item's time, so that its build may go. Once the
2044
+ * runway stops falling, the item's time comes within it, at `coveredAt`.
2045
+ */
2046
+ function covered(item) {
2047
+ const at = atMono(item);
2048
+ if (at === undefined || at <= now.mono)
2049
+ return true;
2050
+ const { end, seconds } = runwayTerms();
2051
+ return Math.max(end, now.mono) + seconds * 1000 >= at || now.mono >= at - seconds * 1000;
2052
+ }
2053
+ function coveredAt(item) {
2054
+ const at = atMono(item);
2055
+ return at === undefined || covered(item) ? undefined : at - runwayTerms().seconds * 1000;
2056
+ }
2057
+ /** Milliseconds of Ready air ahead of `value`'s Ready clip at `index`, less what plays. */
2058
+ function readyAheadMs(value, index) {
2059
+ return readyOf(value)
2060
+ .slice(0, index)
2061
+ .filter(airs)
2062
+ .reduce((total, other) => total + other.seconds * 1000, 0);
2063
+ }
2064
+ /**
2065
+ * When a held item Ready on `value` behind `aheadMs` of Ready air comes within the margin of
2066
+ * airing, as the clip playing there airs; undefined if it doesn't before that clip ends.
2067
+ */
2068
+ function exposedAt(value, aheadMs) {
2069
+ const end = playingEndOf(value);
2070
+ return end === undefined || aheadMs >= exposureMarginMs
2071
+ ? undefined
2072
+ : end + aheadMs - exposureMarginMs;
2073
+ }
2074
+ /**
2075
+ * Whether a clip of `seconds` built on `target` now would finish airing before
2076
+ * the session's cap, counting everything that airs ahead of it there: the
2077
+ * playing clip's rest, its Ready clips, its builds in flight and, for a
2078
+ * replacement, what the session on air still has. A clip no fresh session
2079
+ * could air whole is not held back.
2080
+ */
2081
+ function fits(target, seconds) {
2082
+ if (target.lifetimeMs === Infinity)
2083
+ return true;
2084
+ const ratio = estimates().length;
2085
+ const lengthMs = seconds * ratio * 1000;
2086
+ const marginMs = lookaheadMarginSeconds * 1000;
2087
+ if (lengthMs > target.lifetimeMs - marginMs)
2088
+ return true;
2089
+ const queuedMs = (value) => readyOf(value)
2090
+ .filter(airs)
2091
+ .reduce((total, clip) => total + clip.seconds * 1000, 0);
2092
+ const onAir = session(state.air);
2093
+ const startsAt = target.id === state.air || onAir === undefined
2094
+ ? now.mono + playingRestMs(target)
2095
+ : now.mono + playingRestMs(onAir) + queuedMs(onAir);
2096
+ const buildingMs = [...items.values()]
2097
+ .filter((item) => item.sessionId === target.id && (item.phase === "Building" || item.phase === "Unknown"))
2098
+ .reduce((total, item) => total + item.spec.seconds * ratio * 1000, 0) +
2099
+ (target.source?.building ?? [])
2100
+ .filter((clip) => clip.tag?._tag === "Filler")
2101
+ .reduce((total, clip) => total + clip.seconds * 1000, 0);
2102
+ return (startsAt + queuedMs(target) + buildingMs + lengthMs <=
2103
+ target.openedAt + target.lifetimeMs - marginMs);
2104
+ }
2105
+ /** The runway filler refills below; protecting the air, it covers the next item's build too. */
2106
+ function fillerFloor() {
2107
+ const floor = clipFloor();
2108
+ if (floor <= 0 || config.filler?.protect !== "air")
2109
+ return floor;
2110
+ const next = eligible(floor)[0];
2111
+ const build = next === undefined || !protects(next) ? undefined : buildOf(next, next.spec.continuity);
2112
+ return build === undefined ? floor : Math.max(floor, build + lookaheadMarginSeconds);
2113
+ }
2114
+ function clipFloor() {
2115
+ const filler = config.filler;
2116
+ if (filler === undefined || filler.floor <= 0)
2117
+ return 0;
2118
+ const p95 = estimates().build?.p95;
2119
+ // A floor covers one p95 build of the next filler clip, so a refill started there is Ready in time.
2120
+ return p95 === undefined
2121
+ ? filler.floor
2122
+ : Math.max(filler.floor, p95 * filler.lengths.min + lookaheadMarginSeconds);
2123
+ }
2124
+ /**
2125
+ * What a continuing item continues from: the clip that airs just before it on
2126
+ * the same session, or the one that will by the time it is built
2127
+ * (`airsBefore`), if the provider still offers it. It waits while that
2128
+ * predecessor is an item not built yet; there is nothing to continue across sessions.
2129
+ */
2130
+ function continuation(item, target) {
2131
+ const place = rankItem(item);
2132
+ const before = (rank) => compareRank(rank, place) < 0;
2133
+ let best;
2134
+ for (const clip of readyOf(target)) {
2135
+ const rank = rankClip(clip);
2136
+ const owner = itemOf(clip);
2137
+ // What this item replaces, or a batch is taking off, is no predecessor.
2138
+ if (before(rank) &&
2139
+ owner?.withdraw === undefined &&
2140
+ (owner === undefined || (!superseded.has(owner.spec.key) && replacedOf(item) !== owner)) &&
2141
+ (best === undefined || compareRank(rank, best.rank) > 0))
2142
+ best = { rank, clipId: clip.clipId };
2143
+ }
2144
+ for (const other of items.values())
2145
+ if (other !== item &&
2146
+ other.withdraw === undefined &&
2147
+ !superseded.has(other.spec.key) &&
2148
+ replacedOf(item) !== other &&
2149
+ (other.phase === "Accepted" || other.phase === "Building" || other.phase === "Unknown")) {
2150
+ const rank = rankItem(other);
2151
+ if (before(rank) && (best === undefined || compareRank(rank, best.rank) > 0))
2152
+ best = { rank, clipId: undefined };
2153
+ }
2154
+ if (best !== undefined && best.clipId === undefined)
2155
+ return { _tag: "wait" };
2156
+ const predecessor = best?.clipId ?? (target.id === state.air ? target.source?.playing?.clipId : undefined);
2157
+ if (predecessor === undefined)
2158
+ return { _tag: "from", clipId: undefined };
2159
+ const from = airsBefore(item, predecessor, target);
2160
+ return target.source?.continuable.includes(from.clipId) === true
2161
+ ? { _tag: "from", ...from }
2162
+ : { _tag: "from", clipId: undefined };
2163
+ }
2164
+ /**
2165
+ * The clip that airs just before `item` if its continued build starts now:
2166
+ * `predecessor`, unless that build is projected Ready only after
2167
+ * `predecessor` ends. The Ready clips behind it then air first, and the item
2168
+ * continues from, and follows, the one that will be playing when it is Ready,
2169
+ * or the last of them. A clip still to be built is never chosen: H3 continues
2170
+ * only from a clip that finished generating.
2171
+ */
2172
+ function airsBefore(item, predecessor, target) {
2173
+ const rate = continuedBuildRate(state.samples);
2174
+ if (rate === undefined)
2175
+ return { clipId: predecessor };
2176
+ const readyAt = now.mono + (rate * item.spec.seconds + lookaheadMarginSeconds) * 1000;
2177
+ const onAir = target.id === state.air ? target.source?.playing : undefined;
2178
+ const queued = readyOf(target)
2179
+ .filter(airs)
2180
+ .sort((a, b) => compareRank(rankClip(a), rankClip(b)));
2181
+ const after = onAir?.clipId === predecessor
2182
+ ? 0
2183
+ : queued.findIndex((clip) => clip.clipId === predecessor) + 1;
2184
+ if (after === 0 && onAir?.clipId !== predecessor)
2185
+ return { clipId: predecessor };
2186
+ let end = now.mono +
2187
+ (onAir === undefined ? 0 : playingRestMs(target)) +
2188
+ queued.slice(0, after).reduce((total, clip) => total + clip.seconds * 1000, 0);
2189
+ if (readyAt <= end)
2190
+ return { clipId: predecessor };
2191
+ const behind = queued.slice(after);
2192
+ for (const [index, clip] of behind.entries()) {
2193
+ end += clip.seconds * 1000;
2194
+ if (readyAt <= end || index === behind.length - 1)
2195
+ return { clipId: clip.clipId, follows: clip.clipId };
2196
+ }
2197
+ return { clipId: predecessor };
2198
+ }
2199
+ function wake() {
2200
+ const times = [];
2201
+ const later = (at) => {
2202
+ if (at !== undefined && at > now.mono && Number.isFinite(at))
2203
+ times.push(at);
2204
+ };
2205
+ for (const item of items.values()) {
2206
+ const at = atMono(item);
2207
+ // A settled item's clip may still be listed Ready until a read shows it gone: its time
2208
+ // still ranks it there.
2209
+ later(at);
2210
+ if (item.phase === "Settled")
2211
+ continue;
2212
+ later(item.notBefore);
2213
+ later(item.startBy);
2214
+ later(item.retryAt);
2215
+ if (at !== undefined &&
2216
+ item.spec.start._tag === "At" &&
2217
+ typeof item.spec.start.late === "object")
2218
+ later(at + item.spec.start.late.skipAfterMs);
2219
+ if (item.unknownSince !== undefined)
2220
+ later(item.unknownSince + config.unknownTimeoutMs);
2221
+ if (item.phase === "Started")
2222
+ for (const [index, cue] of item.spec.cues.entries())
2223
+ if (!item.fired.includes(index))
2224
+ later(cueAt(item, cue));
2225
+ if (item.phase === "Accepted")
2226
+ later(coveredAt(item));
2227
+ if (lateWhenProjected(item))
2228
+ later(item.startBy - projection(item).after);
2229
+ }
2230
+ for (const value of state.sessions) {
2231
+ for (const entry of value.unknownFiller)
2232
+ later(entry.since + config.unknownTimeoutMs);
2233
+ later(value.openedAt + value.lifetimeMs - config.leadMs);
2234
+ later(value.openedAt + value.lifetimeMs);
2235
+ if (value.lastEndedAt !== undefined)
2236
+ later(value.lastEndedAt + config.graceMs);
2237
+ later(value.autoplayRetry?.at);
2238
+ later(value.moveRetryAt);
2239
+ for (const [index, clip] of readyOf(value).entries()) {
2240
+ const item = itemOf(clip);
2241
+ if (item?.phase === "Ready" && item.mode === "held")
2242
+ later(exposedAt(value, readyAheadMs(value, index)));
2243
+ }
2244
+ }
2245
+ if (!state.openingPaused)
2246
+ later(state.openRetryAt);
2247
+ later(state.filler.retryAt);
2248
+ // The runway falls while a clip plays: wake as it falls to the filler's floor, which,
2249
+ // protecting the air, also covers the next item's build. Below it, filler goes at once.
2250
+ later(fallsTo(clipFloor()));
2251
+ later(fallsTo(fillerFloor()));
2252
+ return times.length === 0 ? undefined : Math.min(...times);
2253
+ }
2254
+ };
2255
+ /**
2256
+ * Applies one input: a pure function of the state, the input, the configuration and the time.
2257
+ * A decision can make another due at the same instant, as a build sent can leave a firm item
2258
+ * projected late, or a session lost at its cap settle what a batch waits on: the plan looks
2259
+ * again until nothing more is due, so none of it waits for a later input or wake.
2260
+ */
2261
+ export const step = dual(4, (config, previous, input, now) => {
2262
+ let result = decide(config, previous, input, now);
2263
+ for (let look = 1, decided = result.actions.length; decided > 0 && look < maxLooks; look++) {
2264
+ const again = decide(config, result.state, { _tag: "Tick" }, now);
2265
+ result = { ...again, actions: [...result.actions, ...again.actions] };
2266
+ decided = again.actions.length;
2267
+ }
2268
+ return result;
2269
+ });
2270
+ /**
2271
+ * What a withdrawal of `key` finds once nothing can change: the recorded fate of
2272
+ * the item, or of a group's parts, answered as a withdrawal waiting on them would be.
2273
+ */
2274
+ export const fate = dual(2, (state, key) => combined((state.groups.get(key)?.parts ?? [key]).flatMap((part) => {
2275
+ const item = state.items.get(part);
2276
+ return item === undefined ? [] : [fateOf(item)];
2277
+ })));
2278
+ /**
2279
+ * Equal clips that tile the gap to an anchor within the provider's lengths; else the shortest.
2280
+ * A tile that falls short costs a whole clip more, so each asks for no less than its share. A
2281
+ * provider that makes clips longer than asked covers the share anyway: H3 aligns a length up to
2282
+ * its frame grid, which airs 5 s as 5.167 s but 5.806 s as 5.875 s, so a ratio learned on one
2283
+ * length does not carry to another. Only one that makes them shorter is asked for more.
2284
+ */
2285
+ const fillLength = (gapSeconds, lengths, ratio) => {
2286
+ if (!(gapSeconds > 0))
2287
+ return lengths.min;
2288
+ const shortfall = Math.min(1, ratio);
2289
+ const pieces = Math.max(1, Math.ceil(gapSeconds / (lengths.max * shortfall)));
2290
+ return Math.min(lengths.max, Math.max(lengths.min, gapSeconds / pieces / shortfall));
2291
+ };
2292
+ /** The public view of the plan. */
2293
+ export const view = dual(3, (config, state, now) => {
2294
+ const own = (clip) => clip.tag?._tag === "Item" ? clip.tag.key : clip.tag?._tag === "Filler" ? "filler" : "other";
2295
+ const playing = state.sessions.find((value) => value.id === state.air)?.playing;
2296
+ return {
2297
+ accepting: state.accepting,
2298
+ runwaySeconds: securedOf(state, now),
2299
+ playing: playing === undefined
2300
+ ? null
2301
+ : { key: own(playing), startedAt: playing.wall, seconds: playing.seconds },
2302
+ lanes: config.lanes.map((lane, index) => ({
2303
+ name: lane.name,
2304
+ keys: [...state.items.values()]
2305
+ .filter((item) => item.spec.lane === index && item.phase !== "Settled")
2306
+ .sort((a, b) => a.order - b.order)
2307
+ .map((item) => item.spec.key),
2308
+ })),
2309
+ sessions: state.sessions.map((value) => ({
2310
+ sessionId: value.id,
2311
+ role: value.id === state.air ? "on-air" : value.retiring ? "retiring" : "replacement",
2312
+ ready: (value.source?.ready ?? []).map(own),
2313
+ })),
2314
+ starved: state.starved,
2315
+ estimates: estimatesOf(state.samples),
2316
+ };
2317
+ });
2318
+ /** The status of an item nothing can settle any more. */
2319
+ export const indeterminate = { _tag: "Unknown", terminal: true };
2320
+ /** What an as-run status settles: the item's start, its outcome, or both. */
2321
+ export const decides = (status) => {
2322
+ switch (status._tag) {
2323
+ case "Started":
2324
+ return { started: status };
2325
+ // An item's start is always published before its end.
2326
+ case "Ended":
2327
+ return { outcome: status };
2328
+ case "Dropped":
2329
+ case "Failed":
2330
+ case "Unobserved":
2331
+ return { started: status, outcome: status };
2332
+ case "Unknown":
2333
+ return status.terminal === true ? { started: indeterminate, outcome: indeterminate } : {};
2334
+ default:
2335
+ return {};
2336
+ }
2337
+ };
2338
+ //# sourceMappingURL=policy.js.map