reactor-effect-client 0.3.4 → 0.5.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 (76) hide show
  1. package/README.md +7 -3
  2. package/dist/SessionTypes.d.ts +0 -11
  3. package/dist/SessionTypes.d.ts.map +1 -1
  4. package/dist/SessionTypes.js.map +1 -1
  5. package/dist/coordinator/_internal/client.d.ts +0 -6
  6. package/dist/coordinator/_internal/client.d.ts.map +1 -1
  7. package/dist/coordinator/_internal/client.js.map +1 -1
  8. package/dist/coordinator/_internal/recording.d.ts +0 -2
  9. package/dist/coordinator/_internal/recording.d.ts.map +1 -1
  10. package/dist/coordinator/_internal/recording.js.map +1 -1
  11. package/dist/coordinator/_internal/schemas.d.ts +0 -12
  12. package/dist/coordinator/_internal/schemas.d.ts.map +1 -1
  13. package/dist/coordinator/_internal/schemas.js.map +1 -1
  14. package/dist/duration.d.ts +2 -0
  15. package/dist/duration.d.ts.map +1 -1
  16. package/dist/duration.js +1 -1
  17. package/dist/duration.js.map +1 -1
  18. package/dist/h3/types.d.ts +0 -8
  19. package/dist/h3/types.d.ts.map +1 -1
  20. package/dist/orchestration/elapsed.d.ts +9 -0
  21. package/dist/orchestration/elapsed.d.ts.map +1 -0
  22. package/dist/orchestration/elapsed.js +8 -0
  23. package/dist/orchestration/elapsed.js.map +1 -0
  24. package/dist/orchestration/h3-source.d.ts.map +1 -1
  25. package/dist/orchestration/h3-source.js +34 -11
  26. package/dist/orchestration/h3-source.js.map +1 -1
  27. package/dist/orchestration/index.d.ts +2 -2
  28. package/dist/orchestration/index.d.ts.map +1 -1
  29. package/dist/orchestration/index.js +1 -1
  30. package/dist/orchestration/index.js.map +1 -1
  31. package/dist/orchestration/queries.d.ts.map +1 -1
  32. package/dist/orchestration/queries.js +3 -0
  33. package/dist/orchestration/queries.js.map +1 -1
  34. package/dist/orchestration/references.d.ts +0 -2
  35. package/dist/orchestration/references.d.ts.map +1 -1
  36. package/dist/orchestration/references.js.map +1 -1
  37. package/dist/orchestration/renewal-state.d.ts +18 -4
  38. package/dist/orchestration/renewal-state.d.ts.map +1 -1
  39. package/dist/orchestration/renewal-state.js +9 -4
  40. package/dist/orchestration/renewal-state.js.map +1 -1
  41. package/dist/orchestration/renewal.d.ts +12 -9
  42. package/dist/orchestration/renewal.d.ts.map +1 -1
  43. package/dist/orchestration/renewal.js +104 -15
  44. package/dist/orchestration/renewal.js.map +1 -1
  45. package/dist/orchestration/scheduler-key.d.ts +13 -0
  46. package/dist/orchestration/scheduler-key.d.ts.map +1 -0
  47. package/dist/orchestration/scheduler-key.js +62 -0
  48. package/dist/orchestration/scheduler-key.js.map +1 -0
  49. package/dist/orchestration/scheduler-policy.d.ts +81 -0
  50. package/dist/orchestration/scheduler-policy.d.ts.map +1 -0
  51. package/dist/orchestration/scheduler-policy.js +203 -0
  52. package/dist/orchestration/scheduler-policy.js.map +1 -0
  53. package/dist/orchestration/scheduler.d.ts +182 -0
  54. package/dist/orchestration/scheduler.d.ts.map +1 -0
  55. package/dist/orchestration/scheduler.js +1430 -0
  56. package/dist/orchestration/scheduler.js.map +1 -0
  57. package/dist/orchestration/source-slot.d.ts +4 -1
  58. package/dist/orchestration/source-slot.d.ts.map +1 -1
  59. package/dist/orchestration/source-slot.js +36 -5
  60. package/dist/orchestration/source-slot.js.map +1 -1
  61. package/dist/orchestration/types.d.ts +77 -6
  62. package/dist/orchestration/types.d.ts.map +1 -1
  63. package/dist/orchestration/types.js.map +1 -1
  64. package/dist/session/files.d.ts +0 -2
  65. package/dist/session/files.d.ts.map +1 -1
  66. package/dist/session/files.js.map +1 -1
  67. package/dist/simulation/_internal/source.d.ts.map +1 -1
  68. package/dist/simulation/_internal/source.js +42 -28
  69. package/dist/simulation/_internal/source.js.map +1 -1
  70. package/dist/simulation/types.d.ts +0 -4
  71. package/dist/simulation/types.d.ts.map +1 -1
  72. package/package.json +2 -2
  73. package/dist/orchestration/lineup.d.ts +0 -90
  74. package/dist/orchestration/lineup.d.ts.map +0 -1
  75. package/dist/orchestration/lineup.js +0 -222
  76. package/dist/orchestration/lineup.js.map +0 -1
@@ -0,0 +1,1430 @@
1
+ import * as Clock from "effect/Clock";
2
+ import * as Context from "effect/Context";
3
+ import * as Deferred from "effect/Deferred";
4
+ import * as Duration from "effect/Duration";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Exit from "effect/Exit";
7
+ import * as Layer from "effect/Layer";
8
+ import * as Option from "effect/Option";
9
+ import * as PubSub from "effect/PubSub";
10
+ import * as Queue from "effect/Queue";
11
+ import * as Result from "effect/Result";
12
+ import * as Schema from "effect/Schema";
13
+ import * as Stream from "effect/Stream";
14
+ import * as SubscriptionRef from "effect/SubscriptionRef";
15
+ import { duration } from "../duration.js";
16
+ import { parsedInput, ReactorError } from "../errors.js";
17
+ import { monotonicMillis } from "./elapsed.js";
18
+ import { captureRequest, PolicyFailure } from "./request.js";
19
+ import { activeIds } from "./routing.js";
20
+ import { fillerIndexFromKey, fillerKey, isReservedSchedulerKey, keyedRequest, keyFromProviderMetadata, } from "./scheduler-key.js";
21
+ import { plan, runwaySeconds } from "./scheduler-policy.js";
22
+ import { Engine } from "./types.js";
23
+ /** Stable caller identity for a scheduled item. */
24
+ export const ItemKey = Schema.NonEmptyString.pipe(Schema.brand("ItemKey"));
25
+ const isFirstDecisive = (status) => status._tag === "Started" ||
26
+ status._tag === "Ended" ||
27
+ status._tag === "Dropped" ||
28
+ status._tag === "Failed" ||
29
+ status._tag === "Unobserved" ||
30
+ (status._tag === "Unknown" && status.terminal === true);
31
+ export class KeyMismatch extends Schema.TaggedError()("KeyMismatch", {
32
+ key: ItemKey,
33
+ }) {
34
+ static of(key) {
35
+ return new KeyMismatch({ key });
36
+ }
37
+ }
38
+ export class WouldMissDeadline extends Schema.TaggedError()("WouldMissDeadline", { key: ItemKey }) {
39
+ static of(key) {
40
+ return new WouldMissDeadline({ key });
41
+ }
42
+ }
43
+ const maxEarlyClipIds = 4096;
44
+ const invalid = (message) => PolicyFailure.refuse("InvalidRequest", message, "submit");
45
+ const ownedData = (input, fields, name) => {
46
+ if (input === null || typeof input !== "object" || Array.isArray(input))
47
+ return Result.fail(invalid(`${name} must be an object`));
48
+ const descriptors = Object.getOwnPropertyDescriptors(input);
49
+ if (Object.getOwnPropertySymbols(input).length > 0 ||
50
+ Object.keys(descriptors).some((field) => !fields.includes(field) || !("value" in descriptors[field])))
51
+ return Result.fail(invalid(`${name} has unsupported fields or accessors`));
52
+ return Result.succeed(Object.fromEntries(Object.entries(descriptors).map(([field, descriptor]) => [field, descriptor.value])));
53
+ };
54
+ const captureDuration = (input) => Effect.gen(function* () {
55
+ if (input === null || typeof input !== "object" || Duration.isDuration(input))
56
+ return input;
57
+ if (Array.isArray(input)) {
58
+ const descriptors = Object.getOwnPropertyDescriptors(input);
59
+ if (Object.getOwnPropertySymbols(input).length > 0 ||
60
+ Object.keys(descriptors).some((field) => !["0", "1", "length"].includes(field) || !("value" in descriptors[field])) ||
61
+ descriptors.length?.value !== 2 ||
62
+ descriptors["0"] === undefined ||
63
+ descriptors["1"] === undefined)
64
+ return yield* invalid("Duration tuple has unsupported fields or accessors");
65
+ return [descriptors["0"].value, descriptors["1"].value];
66
+ }
67
+ return (yield* Effect.fromResult(ownedData(input, [
68
+ "weeks",
69
+ "days",
70
+ "hours",
71
+ "minutes",
72
+ "seconds",
73
+ "milliseconds",
74
+ "microseconds",
75
+ "nanoseconds",
76
+ ], "Duration")));
77
+ });
78
+ const requestDuration = (input, name, allowZero = false) => Effect.gen(function* () {
79
+ const captured = yield* captureDuration(input);
80
+ return yield* parsedInput(() => Duration.toMillis(duration(captured, name, { allowZero, allowNegative: name === "startBy" })), "submit").pipe(Effect.mapError((error) => invalid(error.message)));
81
+ });
82
+ /** Capture scheduling fields before reading the request's placement fields. */
83
+ const captureItem = (input, lanes) => Effect.gen(function* () {
84
+ if (input === null || typeof input !== "object" || Array.isArray(input))
85
+ return yield* invalid("Scheduled item must be an object");
86
+ const descriptors = Object.getOwnPropertyDescriptors(input);
87
+ if (Object.getOwnPropertySymbols(input).length > 0 ||
88
+ Object.keys(descriptors).some((field) => !["key", "lane", "request", "window", "start"].includes(field) ||
89
+ !("value" in descriptors[field])))
90
+ return yield* invalid("Scheduled item has unsupported fields or accessors");
91
+ const key = yield* Schema.decodeUnknownEffect(ItemKey)(descriptors.key?.value).pipe(Effect.mapError(() => invalid("Scheduled item key must be nonempty")));
92
+ if (isReservedSchedulerKey(key))
93
+ return yield* invalid("Scheduled item key is reserved");
94
+ const lane = descriptors.lane?.value;
95
+ if (typeof lane !== "string" || !lanes.has(lane))
96
+ return yield* invalid("Scheduled item lane is not configured");
97
+ const rawRequest = descriptors.request?.value;
98
+ const request = yield* captureRequest(rawRequest);
99
+ if (request.position !== undefined ||
100
+ request.before !== undefined ||
101
+ request.sameSessionAs !== undefined ||
102
+ request.continueFrom !== undefined ||
103
+ request.sequence !== undefined)
104
+ return yield* invalid("Scheduler owns placement and source selection");
105
+ const rawWindow = descriptors.window?.value;
106
+ const window = rawWindow === undefined
107
+ ? undefined
108
+ : yield* Effect.fromResult(ownedData(rawWindow, ["notBefore", "startBy", "firm"], "Scheduled window"));
109
+ const notBeforeOffsetMs = window?.notBefore === undefined
110
+ ? undefined
111
+ : yield* requestDuration(window.notBefore, "notBefore", true);
112
+ const startByOffsetMs = window?.startBy === undefined
113
+ ? undefined
114
+ : yield* requestDuration(window.startBy, "startBy", true);
115
+ if (window !== undefined &&
116
+ (typeof window.firm !== "boolean" ||
117
+ (notBeforeOffsetMs !== undefined &&
118
+ startByOffsetMs !== undefined &&
119
+ startByOffsetMs >= 0 &&
120
+ notBeforeOffsetMs > startByOffsetMs)))
121
+ return yield* invalid("Scheduled window is inconsistent");
122
+ const rawStart = descriptors.start?.value;
123
+ const start = rawStart === undefined
124
+ ? undefined
125
+ : yield* Effect.fromResult(ownedData(rawStart, ["_tag", "time", "late"], "Scheduled start mode"));
126
+ let atWallMs;
127
+ let late = "nextBoundary";
128
+ if (start?._tag === "Follow" && (start.time !== undefined || start.late !== undefined))
129
+ return yield* invalid("Follow cannot contain an anchor or lateness policy");
130
+ if (start !== undefined && start._tag !== "Follow") {
131
+ if (start._tag !== "At" || !Number.isFinite(start.time))
132
+ return yield* invalid("Scheduled start mode is invalid");
133
+ atWallMs = start.time;
134
+ const policy = yield* Effect.fromResult(ownedData(start.late, ["_tag", "by"], "Scheduled lateness policy"));
135
+ if (policy._tag === "drop" && policy.by === undefined)
136
+ late = "drop";
137
+ else if (policy._tag === "skipIfLaterThan")
138
+ late = {
139
+ skipIfLaterThanMs: yield* requestDuration(policy.by, "skipIfLaterThan", true),
140
+ };
141
+ else if (policy._tag !== "nextBoundary" || policy.by !== undefined)
142
+ return yield* invalid("Scheduled lateness policy is invalid");
143
+ }
144
+ return {
145
+ key,
146
+ lane,
147
+ request,
148
+ notBeforeOffsetMs,
149
+ startByOffsetMs,
150
+ firm: window?.firm === true,
151
+ atWallMs,
152
+ late,
153
+ fingerprint: JSON.stringify({
154
+ lane,
155
+ request,
156
+ notBeforeOffsetMs,
157
+ startByOffsetMs,
158
+ firm: window?.firm === true,
159
+ atWallMs,
160
+ late,
161
+ }),
162
+ };
163
+ });
164
+ export const lineup = (filler) => ({
165
+ lanes: [{ name: "line" }],
166
+ filler,
167
+ });
168
+ export class Scheduler extends Context.Service()("reactor-effect-client/Orchestration/Scheduler") {
169
+ static lineup = lineup;
170
+ }
171
+ export const makeScheduler = (options) => Effect.gen(function* () {
172
+ const engine = yield* Engine;
173
+ const clock = yield* Clock.Clock;
174
+ const lanes = yield* parsedInput(() => {
175
+ if (options.lanes.length === 0 ||
176
+ options.lanes.some((lane) => typeof lane.name !== "string" || lane.name.length === 0) ||
177
+ new Set(options.lanes.map((lane) => lane.name)).size !== options.lanes.length)
178
+ throw ReactorError.fromCode("InvalidInput", "Scheduler lanes must be unique and nonempty");
179
+ return options.lanes.map((lane) => lane.name);
180
+ }, "makeScheduler");
181
+ const { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory } = yield* parsedInput(() => {
182
+ const floorSeconds = Duration.toMillis(duration(options.filler.runway.floor, "runway floor", { allowZero: true })) / 1000;
183
+ const targetSeconds = Duration.toMillis(duration(options.filler.runway.target, "runway target")) / 1000;
184
+ const maxBuildsInFlight = options.maxBuildsInFlight ?? 1;
185
+ const maxHistory = options.maxHistory ?? 4096;
186
+ if (floorSeconds > targetSeconds ||
187
+ !Number.isSafeInteger(maxBuildsInFlight) ||
188
+ maxBuildsInFlight < 1 ||
189
+ maxBuildsInFlight > 1024 ||
190
+ !Number.isSafeInteger(maxHistory) ||
191
+ maxHistory < 0 ||
192
+ maxHistory > 65_536)
193
+ throw ReactorError.fromCode("InvalidInput", "Scheduler runway or build cap is invalid");
194
+ return { floorSeconds, targetSeconds, maxBuildsInFlight, maxHistory };
195
+ }, "makeScheduler");
196
+ const captureFiller = (index, runway, targetSeconds) => Effect.gen(function* () {
197
+ const request = yield* captureRequest(options.filler.clip({
198
+ index,
199
+ runwaySeconds: runway,
200
+ ...(targetSeconds === undefined ? {} : { targetSeconds }),
201
+ }));
202
+ if (request.position !== undefined ||
203
+ request.before !== undefined ||
204
+ request.sameSessionAs !== undefined ||
205
+ request.continueFrom !== undefined ||
206
+ request.sequence !== undefined)
207
+ return yield* PolicyFailure.refuse("InvalidRequest", "Scheduler filler cannot choose placement or source");
208
+ return request;
209
+ });
210
+ let fillerIndex = 0;
211
+ let fillerRetryAtMs = 0;
212
+ let unknownFiller;
213
+ let upcomingFiller = yield* captureFiller(0, 0);
214
+ const inbox = yield* Queue.unbounded();
215
+ const commandQueue = yield* Queue.unbounded();
216
+ const events = yield* PubSub.unbounded();
217
+ yield* Effect.addFinalizer(() => Effect.gen(function* () {
218
+ yield* Queue.shutdown(commandQueue);
219
+ yield* Queue.shutdown(inbox);
220
+ yield* PubSub.shutdown(events);
221
+ }));
222
+ const stateRef = yield* SubscriptionRef.make({
223
+ accepting: true,
224
+ runwaySeconds: 0,
225
+ playing: null,
226
+ lanes: lanes.map((name) => ({ name, keys: [] })),
227
+ sessions: [],
228
+ starved: 0,
229
+ });
230
+ const initialized = yield* Deferred.make();
231
+ const stopped = yield* Deferred.make();
232
+ const closedCall = Deferred.await(stopped).pipe(Effect.flatMap((cause) => PolicyFailure.refuse("SessionClosed", cause.message)));
233
+ const items = new Map();
234
+ const history = new Map();
235
+ const owned = new Map();
236
+ // An event can overtake its enqueue reply. Keep only the evidence needed to
237
+ // decide that clip's fate until the one in-flight command returns its ID.
238
+ const earlyClipEvents = new Map();
239
+ const removedIds = new Set();
240
+ const filler = new Map();
241
+ const playingStartedMs = new Map();
242
+ let admission = 0;
243
+ let accepting = true;
244
+ let starved = 0;
245
+ let refillActive = false;
246
+ let ended;
247
+ let drainFailure;
248
+ const drainReplies = [];
249
+ const pendingWithdrawals = new Map();
250
+ const pendingBuilds = new Set();
251
+ const pendingAtDeferrals = new Set();
252
+ const pendingItemRemovals = new Set();
253
+ const pendingFillerRemovals = new Set();
254
+ const removalRetryAtMs = new Map();
255
+ const removalUnknownAtSerial = new Map();
256
+ let commandCount = 0;
257
+ let snapshotAcquiredSerial = 0;
258
+ let lastProcessedSnapshotSerial = 0;
259
+ let draining = false;
260
+ let finishAccepted = false;
261
+ let autoplayPaused = false;
262
+ let drained = false;
263
+ let observationReady = false;
264
+ let blockedMove;
265
+ const emit = (entry, status) => Effect.gen(function* () {
266
+ if (JSON.stringify(entry.status) === JSON.stringify(status))
267
+ return;
268
+ entry.status = status;
269
+ entry.phase =
270
+ status._tag === "Accepted" ||
271
+ status._tag === "Building" ||
272
+ status._tag === "Ready" ||
273
+ status._tag === "Started"
274
+ ? status._tag
275
+ : status._tag === "Unknown" && status.terminal !== true
276
+ ? "Unknown"
277
+ : "Terminal";
278
+ yield* PubSub.publish(events, {
279
+ key: entry.key,
280
+ at: clock.currentTimeMillisUnsafe(),
281
+ status,
282
+ });
283
+ if (isFirstDecisive(status))
284
+ yield* Deferred.succeed(entry.firstDecisiveWaiter, status);
285
+ if (status._tag === "Started")
286
+ yield* Deferred.succeed(entry.startedWaiter, status);
287
+ if (status._tag === "Ended" ||
288
+ status._tag === "Dropped" ||
289
+ status._tag === "Failed" ||
290
+ status._tag === "Unobserved" ||
291
+ (status._tag === "Unknown" && status.terminal === true)) {
292
+ yield* Deferred.succeed(entry.startedWaiter, status);
293
+ yield* Deferred.succeed(entry.outcomeWaiter, status);
294
+ }
295
+ });
296
+ const closeActor = (cause) => Effect.gen(function* () {
297
+ if (ended !== undefined)
298
+ return;
299
+ ended = cause;
300
+ accepting = false;
301
+ yield* Deferred.succeed(stopped, cause);
302
+ for (const item of items.values()) {
303
+ if (item.phase === "Terminal")
304
+ continue;
305
+ if (item.phase === "Unknown") {
306
+ yield* emit(item, { _tag: "Unknown", terminal: true });
307
+ item.phase = "Terminal";
308
+ }
309
+ else
310
+ yield* emit(item, { _tag: "Failed", reason: { _tag: "Scheduler", cause } });
311
+ }
312
+ const closed = PolicyFailure.refuse("SessionClosed", cause.message);
313
+ for (const [key, pending] of pendingWithdrawals) {
314
+ for (const reply of pending.replies)
315
+ yield* Deferred.fail(reply, closed);
316
+ pendingWithdrawals.delete(key);
317
+ }
318
+ for (const reply of drainReplies.splice(0))
319
+ yield* Deferred.fail(reply, closed);
320
+ yield* SubscriptionRef.update(stateRef, (state) => ({ ...state, accepting: false }));
321
+ });
322
+ const prune = (state) => {
323
+ const active = new Set(activeIds(state));
324
+ if (state.availability === "Ready") {
325
+ for (const id of removedIds)
326
+ if (!active.has(id))
327
+ removedIds.delete(id);
328
+ for (const id of removalRetryAtMs.keys())
329
+ if (!active.has(id))
330
+ removalRetryAtMs.delete(id);
331
+ for (const id of removalUnknownAtSerial.keys())
332
+ if (!active.has(id))
333
+ removalUnknownAtSerial.delete(id);
334
+ }
335
+ for (const [key, item] of items) {
336
+ if (item.phase !== "Terminal" ||
337
+ pendingBuilds.has(key) ||
338
+ pendingWithdrawals.has(key) ||
339
+ pendingAtDeferrals.has(key) ||
340
+ pendingItemRemovals.has(key) ||
341
+ (item.clipId !== undefined && active.has(item.clipId)))
342
+ continue;
343
+ if (item.clipId !== undefined) {
344
+ owned.delete(item.clipId);
345
+ playingStartedMs.delete(item.clipId);
346
+ }
347
+ history.set(key, { fingerprint: item.fingerprint, handle: item.handle });
348
+ items.delete(key);
349
+ }
350
+ while (history.size > maxHistory)
351
+ history.delete(history.keys().next().value);
352
+ };
353
+ const publishState = (state) => {
354
+ const playing = Option.getOrUndefined(state.playing);
355
+ const playingOwner = playing === undefined ? undefined : owned.get(playing.clipId);
356
+ const playingKey = playing === undefined
357
+ ? null
358
+ : playingOwner === undefined
359
+ ? "other"
360
+ : playingOwner._tag === "Filler"
361
+ ? "filler"
362
+ : playingOwner.key;
363
+ return SubscriptionRef.set(stateRef, {
364
+ accepting,
365
+ runwaySeconds: runwaySeconds(state, monotonicMillis(clock), playingStartedMs, owned, [
366
+ ...items.values(),
367
+ ]),
368
+ playing: playingKey,
369
+ lanes: lanes.map((name) => ({
370
+ name,
371
+ keys: [...items.values()]
372
+ .filter((item) => item.lane === name && item.phase !== "Terminal")
373
+ .map((item) => item.key),
374
+ })),
375
+ sessions: state.sessions.map(({ sessionId }) => ({
376
+ sessionId,
377
+ ready: state.ready
378
+ .filter((clip) => clip.sessionId === sessionId)
379
+ .map((clip) => {
380
+ const owner = owned.get(clip.clipId);
381
+ return owner === undefined ? "other" : owner._tag === "Filler" ? "filler" : owner.key;
382
+ }),
383
+ })),
384
+ starved,
385
+ });
386
+ };
387
+ const activeRecords = (state) => [
388
+ ...state.queued,
389
+ ...state.ready,
390
+ ...Option.match(state.building, {
391
+ onNone: () => [],
392
+ onSome: (build) => [build.record],
393
+ }),
394
+ ...Option.match(state.playing, {
395
+ onNone: () => [],
396
+ onSome: (playing) => Option.match(playing.record, {
397
+ onNone: () => [],
398
+ onSome: (record) => [record],
399
+ }),
400
+ }),
401
+ ];
402
+ const knownRecord = (state, clipId) => activeRecords(state).find((record) => record.clipId === clipId);
403
+ const refreshAnchors = () => {
404
+ const nowMs = monotonicMillis(clock);
405
+ const wallMs = clock.currentTimeMillisUnsafe();
406
+ for (const item of items.values())
407
+ if (item.atWallMs !== undefined)
408
+ item.atMs = nowMs + item.atWallMs - wallMs;
409
+ };
410
+ const observed = (state, recoverMissing = false) => Effect.gen(function* () {
411
+ const active = new Set(activeIds(state));
412
+ if (recoverMissing) {
413
+ for (const [clipId, owner] of owned) {
414
+ if (owner._tag !== "Filler" || active.has(clipId))
415
+ continue;
416
+ const tracked = filler.get(owner.index);
417
+ if (tracked?.clipId === clipId &&
418
+ tracked?.acknowledgedAtSnapshotSerial !== undefined &&
419
+ lastProcessedSnapshotSerial <= tracked.acknowledgedAtSnapshotSerial)
420
+ continue;
421
+ const sessionId = owner.sessionId;
422
+ const source = state.sessions.find((entry) => entry.sessionId === sessionId);
423
+ if (sessionId === undefined
424
+ ? state.availability !== "Ready"
425
+ : source !== undefined && source.availability !== "Ready")
426
+ continue;
427
+ owned.delete(clipId);
428
+ if (tracked?.clipId === clipId)
429
+ filler.delete(owner.index);
430
+ playingStartedMs.delete(clipId);
431
+ pendingFillerRemovals.delete(clipId);
432
+ }
433
+ }
434
+ const keyed = new Map();
435
+ for (const record of activeRecords(state)) {
436
+ const key = keyFromProviderMetadata(record.provider.metadata);
437
+ if (key !== undefined && !removedIds.has(record.clipId))
438
+ keyed.set(key, record);
439
+ const index = fillerIndexFromKey(key);
440
+ if (index === undefined || removedIds.has(record.clipId))
441
+ continue;
442
+ const existing = owned.get(record.clipId);
443
+ if (existing?._tag === "Filler") {
444
+ if (existing.sessionId !== record.sessionId)
445
+ owned.set(record.clipId, { ...existing, sessionId: record.sessionId });
446
+ const tracked = filler.get(index);
447
+ if (tracked?.clipId === record.clipId)
448
+ tracked.sessionId = record.sessionId;
449
+ continue;
450
+ }
451
+ if (existing !== undefined)
452
+ continue;
453
+ owned.set(record.clipId, { _tag: "Filler", index, sessionId: record.sessionId });
454
+ filler.set(index, { index, clipId: record.clipId, sessionId: record.sessionId });
455
+ const priorIndex = fillerIndex;
456
+ fillerIndex = Math.max(fillerIndex, index + 1);
457
+ if (unknownFiller?.index === index)
458
+ unknownFiller = undefined;
459
+ if (fillerIndex !== priorIndex)
460
+ upcomingFiller = undefined;
461
+ }
462
+ for (const item of items.values()) {
463
+ if (item.phase === "Terminal")
464
+ continue;
465
+ if (item.clipId === undefined) {
466
+ const resumed = keyed.get(item.key);
467
+ if (resumed !== undefined) {
468
+ item.clipId = resumed.clipId;
469
+ item.sessionId = resumed.sessionId;
470
+ item.unknownSessionId = undefined;
471
+ owned.set(resumed.clipId, { _tag: "Item", key: item.key });
472
+ }
473
+ }
474
+ const clipId = item.clipId;
475
+ if (clipId === undefined)
476
+ continue;
477
+ const playing = Option.getOrUndefined(state.playing);
478
+ if (playing?.clipId === clipId) {
479
+ const record = Option.getOrUndefined(playing.record);
480
+ const at = Option.getOrUndefined(playing.startedAt);
481
+ const startedAtMonotonicMillis = playing.startedAtMonotonicMillis;
482
+ if (record !== undefined &&
483
+ at !== undefined &&
484
+ startedAtMonotonicMillis !== undefined &&
485
+ item.phase !== "Started") {
486
+ item.sessionId = record.sessionId;
487
+ item.startedAtEpochMs = at;
488
+ item.startedAtMonoMs = startedAtMonotonicMillis;
489
+ playingStartedMs.set(clipId, startedAtMonotonicMillis);
490
+ yield* emit(item, {
491
+ _tag: "Started",
492
+ at,
493
+ sessionId: record.sessionId,
494
+ durationSeconds: record.durationSeconds,
495
+ ...((item.atMs ?? item.startByMs) === undefined ||
496
+ startedAtMonotonicMillis <= (item.atMs ?? item.startByMs)
497
+ ? {}
498
+ : { lateByMillis: startedAtMonotonicMillis - (item.atMs ?? item.startByMs) }),
499
+ });
500
+ }
501
+ else if (recoverMissing &&
502
+ (record === undefined || at === undefined) &&
503
+ item.phase !== "Started")
504
+ yield* emit(item, { _tag: "Unobserved" });
505
+ }
506
+ else if (state.ready.some((record) => record.clipId === clipId)) {
507
+ const record = knownRecord(state, clipId);
508
+ item.sessionId = record.sessionId;
509
+ if (item.phase !== "Started")
510
+ yield* emit(item, { _tag: "Ready", sessionId: record.sessionId });
511
+ }
512
+ else if (state.queued.some((record) => record.clipId === clipId) ||
513
+ state.generationOrder.includes(clipId) ||
514
+ Option.getOrUndefined(state.building)?.record.clipId === clipId) {
515
+ item.sessionId = knownRecord(state, clipId)?.sessionId ?? item.sessionId;
516
+ if (item.phase === "Accepted" || item.phase === "Unknown")
517
+ yield* emit(item, { _tag: "Building" });
518
+ }
519
+ else if (state.failed.includes(clipId))
520
+ yield* emit(item, {
521
+ _tag: "Failed",
522
+ reason: { _tag: "Clip", message: "The clip failed unobserved" },
523
+ });
524
+ else if (recoverMissing &&
525
+ item.sessionId !== undefined &&
526
+ (state.sessions.find((source) => source.sessionId === item.sessionId)?.availability ??
527
+ "Ready") === "Ready" &&
528
+ !active.has(clipId) &&
529
+ (item.acknowledgedAtSnapshotSerial === undefined ||
530
+ lastProcessedSnapshotSerial > item.acknowledgedAtSnapshotSerial))
531
+ yield* emit(item, { _tag: "Unobserved" });
532
+ }
533
+ yield* publishState(state);
534
+ });
535
+ const onEvent = (event) => Effect.gen(function* () {
536
+ if (event._tag === "Starved")
537
+ starved++;
538
+ if (event._tag === "SessionFailed") {
539
+ yield* closeActor(event.failure);
540
+ return;
541
+ }
542
+ if (!("clipId" in event))
543
+ return;
544
+ if (commandCount > 0 &&
545
+ (event._tag === "Started" || event._tag === "Ended" || event._tag === "Failed")) {
546
+ const prior = earlyClipEvents.get(event.clipId);
547
+ if (prior === undefined) {
548
+ if (earlyClipEvents.size >= maxEarlyClipIds) {
549
+ // A lost pre-ack start cannot be recast as a definite failure.
550
+ for (const key of pendingBuilds) {
551
+ const item = items.get(key);
552
+ if (item !== undefined && item.phase !== "Terminal" && item.phase !== "Started") {
553
+ yield* emit(item, { _tag: "Unknown", terminal: true });
554
+ item.phase = "Terminal";
555
+ }
556
+ }
557
+ yield* closeActor(ReactorError.fromCode("Overflow", "Unattributed clip evidence exceeded its bound"));
558
+ return;
559
+ }
560
+ earlyClipEvents.set(event.clipId, [event]);
561
+ }
562
+ else if (!prior.some((seen) => event._tag === "Started"
563
+ ? seen._tag === "Started"
564
+ : seen._tag === "Ended" || seen._tag === "Failed"))
565
+ prior.push(event);
566
+ }
567
+ const owner = owned.get(event.clipId);
568
+ if (owner === undefined)
569
+ return;
570
+ if (owner._tag === "Filler") {
571
+ if (event._tag === "Started")
572
+ playingStartedMs.set(event.clipId, event.atMonotonicMillis ?? monotonicMillis(clock));
573
+ if (event._tag === "Ended" || event._tag === "Failed") {
574
+ owned.delete(event.clipId);
575
+ if (filler.get(owner.index)?.clipId === event.clipId)
576
+ filler.delete(owner.index);
577
+ playingStartedMs.delete(event.clipId);
578
+ }
579
+ return;
580
+ }
581
+ const item = items.get(owner.key);
582
+ if (item === undefined || item.phase === "Terminal")
583
+ return;
584
+ switch (event._tag) {
585
+ case "Queued":
586
+ case "Building":
587
+ if (item.phase === "Accepted" || item.phase === "Unknown")
588
+ yield* emit(item, { _tag: "Building" });
589
+ break;
590
+ case "Ready": {
591
+ const record = knownRecord(yield* engine.state, event.clipId);
592
+ if (record !== undefined) {
593
+ item.sessionId = record.sessionId;
594
+ if (item.phase !== "Started")
595
+ yield* emit(item, { _tag: "Ready", sessionId: record.sessionId });
596
+ }
597
+ break;
598
+ }
599
+ case "Started": {
600
+ if (item.phase === "Started")
601
+ break;
602
+ const state = yield* engine.state;
603
+ const record = knownRecord(state, event.clipId);
604
+ const sessionId = record?.sessionId ?? item.sessionId;
605
+ if (sessionId === undefined)
606
+ yield* emit(item, { _tag: "Unobserved" });
607
+ else {
608
+ item.sessionId = sessionId;
609
+ const playing = Option.getOrUndefined(state.playing);
610
+ item.startedAtMonoMs =
611
+ event.atMonotonicMillis ??
612
+ (playing?.clipId === event.clipId
613
+ ? (playing.startedAtMonotonicMillis ?? monotonicMillis(clock))
614
+ : monotonicMillis(clock));
615
+ item.startedAtEpochMs = event.at;
616
+ playingStartedMs.set(event.clipId, item.startedAtMonoMs);
617
+ yield* emit(item, {
618
+ _tag: "Started",
619
+ at: event.at,
620
+ sessionId,
621
+ durationSeconds: event.durationSeconds,
622
+ ...((item.atMs ?? item.startByMs) === undefined ||
623
+ item.startedAtMonoMs <= (item.atMs ?? item.startByMs)
624
+ ? {}
625
+ : { lateByMillis: item.startedAtMonoMs - (item.atMs ?? item.startByMs) }),
626
+ });
627
+ }
628
+ break;
629
+ }
630
+ case "Ended":
631
+ yield* emit(item, item.startedAtMonoMs === undefined
632
+ ? { _tag: "Unobserved" }
633
+ : {
634
+ _tag: "Ended",
635
+ at: event.at ?? clock.currentTimeMillisUnsafe(),
636
+ termination: event.termination,
637
+ airedSeconds: Math.max(0, ((event.atMonotonicMillis ?? monotonicMillis(clock)) - item.startedAtMonoMs) /
638
+ 1000),
639
+ });
640
+ playingStartedMs.delete(event.clipId);
641
+ break;
642
+ case "Failed":
643
+ yield* emit(item, { _tag: "Failed", reason: { _tag: "Clip", message: event.reason } });
644
+ break;
645
+ default:
646
+ break;
647
+ }
648
+ });
649
+ const replayEarlyClipEvents = (clipId) => Effect.gen(function* () {
650
+ const saved = earlyClipEvents.get(clipId);
651
+ earlyClipEvents.delete(clipId);
652
+ if (saved === undefined)
653
+ return;
654
+ for (const event of saved)
655
+ yield* onEvent(event);
656
+ });
657
+ const executeCommand = (command) => {
658
+ switch (command._tag) {
659
+ case "Build":
660
+ case "BuildFiller":
661
+ return Effect.gen(function* () {
662
+ if (command.sessionId === undefined ||
663
+ Option.getOrUndefined((yield* engine.state).preferredSessionId) !== command.sessionId)
664
+ return yield* PolicyFailure.refuse("RouteChanged", "The preferred source changed before dispatch");
665
+ return yield* engine.enqueueOnSource === undefined
666
+ ? engine.enqueue(command.request)
667
+ : engine.enqueueOnSource(command.request, command.sessionId);
668
+ });
669
+ case "RemoveItem":
670
+ case "RemoveFiller":
671
+ case "RemoveDuplicate":
672
+ case "DeferAt":
673
+ return engine.remove(command.clipId);
674
+ case "Order":
675
+ return engine.move(command.clipId, command.position, "playout");
676
+ case "PauseAutoplay":
677
+ return engine.setAutoplay(false);
678
+ }
679
+ };
680
+ const commandWorker = Effect.gen(function* () {
681
+ for (;;) {
682
+ const command = yield* Queue.take(commandQueue);
683
+ const result = yield* Effect.result(executeCommand(command));
684
+ yield* Queue.offer(inbox, { _tag: "CommandDone", command, result });
685
+ }
686
+ });
687
+ const sendCommand = (command) => Effect.gen(function* () {
688
+ if (command._tag === "Build")
689
+ pendingBuilds.add(command.key);
690
+ if (command._tag === "DeferAt")
691
+ pendingAtDeferrals.add(command.key);
692
+ commandCount++;
693
+ yield* Queue.offer(commandQueue, command);
694
+ });
695
+ const canRetryRemoval = (clipId) => monotonicMillis(clock) >= (removalRetryAtMs.get(clipId) ?? 0) &&
696
+ (removalUnknownAtSerial.get(clipId) === undefined ||
697
+ lastProcessedSnapshotSerial > removalUnknownAtSerial.get(clipId));
698
+ const finishWithdrawal = (key, result) => Effect.gen(function* () {
699
+ const pending = pendingWithdrawals.get(key);
700
+ pendingWithdrawals.delete(key);
701
+ if (pending === undefined)
702
+ return;
703
+ for (const reply of pending.replies)
704
+ if (Result.isSuccess(result))
705
+ yield* Deferred.succeed(reply, result.success);
706
+ else
707
+ yield* Deferred.fail(reply, result.failure);
708
+ });
709
+ const sendItemRemoval = (item, reason) => Effect.gen(function* () {
710
+ if (item.clipId === undefined ||
711
+ pendingItemRemovals.has(item.key) ||
712
+ !canRetryRemoval(item.clipId))
713
+ return;
714
+ pendingItemRemovals.add(item.key);
715
+ yield* sendCommand({ _tag: "RemoveItem", key: item.key, clipId: item.clipId, reason });
716
+ });
717
+ const requestWithdrawal = (item, reason, reply) => Effect.gen(function* () {
718
+ if (item.phase === "Started" || item.phase === "Terminal") {
719
+ if (reply !== undefined)
720
+ yield* Deferred.succeed(reply, item.phase === "Started" ? "already-started" : "not-found");
721
+ return;
722
+ }
723
+ const pending = pendingWithdrawals.get(item.key);
724
+ if (pending !== undefined) {
725
+ if (reply !== undefined)
726
+ pending.replies.push(reply);
727
+ return;
728
+ }
729
+ pendingWithdrawals.set(item.key, { reason, replies: reply === undefined ? [] : [reply] });
730
+ if (pendingAtDeferrals.has(item.key))
731
+ return;
732
+ if (item.clipId === undefined) {
733
+ if (item.phase === "Unknown" || pendingBuilds.has(item.key))
734
+ return;
735
+ yield* emit(item, { _tag: "Dropped", reason });
736
+ yield* finishWithdrawal(item.key, Result.succeed("withdrawn"));
737
+ return;
738
+ }
739
+ yield* sendItemRemoval(item, reason);
740
+ });
741
+ const applyPolicy = (action, state) => Effect.gen(function* () {
742
+ switch (action._tag) {
743
+ case "Withdraw": {
744
+ const item = items.get(action.key);
745
+ if (item !== undefined)
746
+ yield* requestWithdrawal(item, action.reason);
747
+ break;
748
+ }
749
+ case "DeferAt":
750
+ if (canRetryRemoval(action.clipId))
751
+ yield* sendCommand({ _tag: "DeferAt", key: action.key, clipId: action.clipId });
752
+ break;
753
+ case "WithdrawFiller":
754
+ for (const clipId of action.clipIds)
755
+ if (!pendingFillerRemovals.has(clipId) && canRetryRemoval(clipId)) {
756
+ pendingFillerRemovals.add(clipId);
757
+ yield* sendCommand({ _tag: "RemoveFiller", clipId });
758
+ }
759
+ break;
760
+ case "Order": {
761
+ const signature = state.ready.map((record) => record.clipId).join(",") +
762
+ ":" +
763
+ action.clipId +
764
+ ":" +
765
+ action.position;
766
+ if (blockedMove !== signature)
767
+ yield* sendCommand({
768
+ _tag: "Order",
769
+ clipId: action.clipId,
770
+ position: action.position,
771
+ signature,
772
+ });
773
+ break;
774
+ }
775
+ case "Build": {
776
+ const item = items.get(action.key);
777
+ if (item?.phase !== "Accepted")
778
+ break;
779
+ const prepared = yield* Effect.result(keyedRequest(item.request, item.key));
780
+ if (Result.isFailure(prepared)) {
781
+ yield* emit(item, {
782
+ _tag: "Failed",
783
+ reason: { _tag: "Command", cause: prepared.failure },
784
+ });
785
+ break;
786
+ }
787
+ yield* sendCommand({
788
+ _tag: "Build",
789
+ key: item.key,
790
+ request: prepared.success,
791
+ sessionId: Option.getOrUndefined(state.preferredSessionId),
792
+ });
793
+ break;
794
+ }
795
+ case "BuildFiller": {
796
+ const captured = upcomingFiller === undefined
797
+ ? yield* Effect.result(captureFiller(fillerIndex, runwaySeconds(state, monotonicMillis(clock), playingStartedMs, owned, [
798
+ ...items.values(),
799
+ ]), action.targetSeconds))
800
+ : Result.succeed(upcomingFiller);
801
+ if (Result.isFailure(captured)) {
802
+ yield* closeActor(captured.failure);
803
+ break;
804
+ }
805
+ upcomingFiller = captured.success;
806
+ const prepared = yield* Effect.result(keyedRequest(captured.success, fillerKey(fillerIndex)));
807
+ if (Result.isFailure(prepared)) {
808
+ yield* closeActor(prepared.failure);
809
+ break;
810
+ }
811
+ yield* sendCommand({
812
+ _tag: "BuildFiller",
813
+ index: fillerIndex,
814
+ request: prepared.success,
815
+ sessionId: Option.getOrUndefined(state.preferredSessionId),
816
+ });
817
+ break;
818
+ }
819
+ }
820
+ });
821
+ const resolveRetiredUnknown = (item) => Effect.gen(function* () {
822
+ if (item.phase !== "Unknown" || item.clipId !== undefined)
823
+ return;
824
+ item.unknownSessionId = undefined;
825
+ // A retired source cannot play this key again, but it may have played
826
+ // during a missed observation. Preserve Unknown instead of replaying it.
827
+ yield* emit(item, { _tag: "Unknown", terminal: true });
828
+ item.phase = "Terminal";
829
+ const pending = pendingWithdrawals.get(item.key);
830
+ if (pending !== undefined)
831
+ yield* finishWithdrawal(item.key, Result.fail(item.unknownCause ??
832
+ PolicyFailure.refuse("SessionRecovering", "The original source retired without an enqueue result")));
833
+ });
834
+ const handleCommandDone = (command, result) => Effect.gen(function* () {
835
+ commandCount--;
836
+ if (ended !== undefined) {
837
+ if (command._tag === "Build")
838
+ pendingBuilds.delete(command.key);
839
+ if (command._tag === "DeferAt")
840
+ pendingAtDeferrals.delete(command.key);
841
+ if (command._tag === "RemoveItem")
842
+ pendingItemRemovals.delete(command.key);
843
+ if (command._tag === "RemoveFiller")
844
+ pendingFillerRemovals.delete(command.clipId);
845
+ return;
846
+ }
847
+ switch (command._tag) {
848
+ case "Build": {
849
+ const item = items.get(command.key);
850
+ pendingBuilds.delete(command.key);
851
+ if (item === undefined)
852
+ break;
853
+ if (Result.isSuccess(result)) {
854
+ const clipId = result.success;
855
+ if (item.clipId !== undefined && item.clipId !== clipId) {
856
+ yield* sendCommand({ _tag: "RemoveDuplicate", clipId });
857
+ break;
858
+ }
859
+ item.clipId = clipId;
860
+ item.acknowledgedAtSnapshotSerial = snapshotAcquiredSerial;
861
+ owned.set(clipId, { _tag: "Item", key: item.key });
862
+ item.sessionId =
863
+ knownRecord(yield* engine.state, clipId)?.sessionId ??
864
+ (engine.enqueueOnSource === undefined ? undefined : command.sessionId);
865
+ item.unknownSessionId = undefined;
866
+ item.unknownCause = undefined;
867
+ yield* replayEarlyClipEvents(clipId);
868
+ const pending = pendingWithdrawals.get(item.key);
869
+ if (pending !== undefined) {
870
+ if (item.phase === "Started" ||
871
+ item.status._tag === "Ended" ||
872
+ item.status._tag === "Unobserved")
873
+ yield* finishWithdrawal(item.key, Result.succeed("already-started"));
874
+ else if (item.phase === "Terminal")
875
+ yield* finishWithdrawal(item.key, Result.succeed("not-found"));
876
+ else
877
+ yield* sendItemRemoval(item, pending.reason);
878
+ }
879
+ else if (item.phase === "Accepted" || item.phase === "Unknown")
880
+ yield* emit(item, { _tag: "Building" });
881
+ yield* observed(yield* engine.state);
882
+ }
883
+ else if (result.failure.context.outcome === "unknown") {
884
+ if (item.clipId === undefined) {
885
+ // A generic engine may re-route between the state read and dispatch.
886
+ // Only an owner-fenced enqueue can attribute its unknown result.
887
+ item.unknownSessionId =
888
+ engine.enqueueOnSource === undefined ? undefined : command.sessionId;
889
+ item.unknownCause = result.failure;
890
+ yield* emit(item, { _tag: "Unknown" });
891
+ }
892
+ }
893
+ else {
894
+ const pending = pendingWithdrawals.get(item.key);
895
+ if (pending !== undefined) {
896
+ yield* emit(item, { _tag: "Dropped", reason: pending.reason });
897
+ yield* finishWithdrawal(item.key, Result.succeed("withdrawn"));
898
+ }
899
+ else if (result.failure.context.outcome !== "not-submitted" ||
900
+ !["QueueFull", "SessionRecovering", "RouteChanged"].includes(result.failure.reason._tag))
901
+ yield* emit(item, {
902
+ _tag: "Failed",
903
+ reason: { _tag: "Command", cause: result.failure },
904
+ });
905
+ else
906
+ item.retryAtMs = monotonicMillis(clock) + 1_000;
907
+ }
908
+ break;
909
+ }
910
+ case "BuildFiller": {
911
+ if (Result.isSuccess(result)) {
912
+ const clipId = result.success;
913
+ const actualSessionId = knownRecord(yield* engine.state, clipId)?.sessionId ??
914
+ (engine.enqueueOnSource === undefined ? undefined : command.sessionId);
915
+ owned.set(clipId, {
916
+ _tag: "Filler",
917
+ index: command.index,
918
+ sessionId: actualSessionId,
919
+ });
920
+ filler.set(command.index, {
921
+ index: command.index,
922
+ clipId,
923
+ sessionId: actualSessionId,
924
+ acknowledgedAtSnapshotSerial: snapshotAcquiredSerial,
925
+ });
926
+ fillerIndex = Math.max(fillerIndex, command.index + 1);
927
+ upcomingFiller = undefined;
928
+ yield* replayEarlyClipEvents(clipId);
929
+ }
930
+ else if (result.failure.context.outcome === "unknown") {
931
+ if (!filler.has(command.index))
932
+ unknownFiller = {
933
+ index: command.index,
934
+ sessionId: engine.enqueueOnSource === undefined ? undefined : command.sessionId,
935
+ };
936
+ }
937
+ else {
938
+ fillerRetryAtMs = monotonicMillis(clock) + 1_000;
939
+ if (result.failure.context.outcome === "replied") {
940
+ fillerIndex = Math.max(fillerIndex, command.index + 1);
941
+ upcomingFiller = undefined;
942
+ }
943
+ }
944
+ yield* observed(yield* engine.state);
945
+ break;
946
+ }
947
+ case "RemoveItem": {
948
+ pendingItemRemovals.delete(command.key);
949
+ const item = items.get(command.key);
950
+ if (Result.isSuccess(result)) {
951
+ removalRetryAtMs.delete(command.clipId);
952
+ removalUnknownAtSerial.delete(command.clipId);
953
+ removedIds.add(command.clipId);
954
+ owned.delete(command.clipId);
955
+ playingStartedMs.delete(command.clipId);
956
+ if (item?.phase === "Started")
957
+ yield* finishWithdrawal(command.key, Result.succeed("already-started"));
958
+ else {
959
+ if (item !== undefined && item.phase !== "Terminal")
960
+ yield* emit(item, { _tag: "Dropped", reason: command.reason });
961
+ yield* finishWithdrawal(command.key, Result.succeed("withdrawn"));
962
+ }
963
+ }
964
+ else {
965
+ removalRetryAtMs.set(command.clipId, monotonicMillis(clock) + 1_000);
966
+ if (result.failure.context.outcome === "unknown")
967
+ removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
968
+ if (item?.phase === "Started")
969
+ yield* finishWithdrawal(command.key, Result.succeed("already-started"));
970
+ else
971
+ yield* finishWithdrawal(command.key, Result.fail(result.failure));
972
+ }
973
+ break;
974
+ }
975
+ case "RemoveFiller": {
976
+ pendingFillerRemovals.delete(command.clipId);
977
+ if (Result.isSuccess(result)) {
978
+ removalRetryAtMs.delete(command.clipId);
979
+ removalUnknownAtSerial.delete(command.clipId);
980
+ removedIds.add(command.clipId);
981
+ const owner = owned.get(command.clipId);
982
+ if (owner?._tag === "Filler" && filler.get(owner.index)?.clipId === command.clipId)
983
+ filler.delete(owner.index);
984
+ owned.delete(command.clipId);
985
+ playingStartedMs.delete(command.clipId);
986
+ }
987
+ else {
988
+ removalRetryAtMs.set(command.clipId, monotonicMillis(clock) + 1_000);
989
+ if (result.failure.context.outcome === "unknown")
990
+ removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
991
+ }
992
+ break;
993
+ }
994
+ case "RemoveDuplicate":
995
+ if (Result.isFailure(result))
996
+ yield* closeActor(result.failure);
997
+ break;
998
+ case "DeferAt": {
999
+ pendingAtDeferrals.delete(command.key);
1000
+ const item = items.get(command.key);
1001
+ if (Result.isFailure(result)) {
1002
+ removalRetryAtMs.set(command.clipId, monotonicMillis(clock) + 1_000);
1003
+ if (result.failure.context.outcome === "unknown")
1004
+ removalUnknownAtSerial.set(command.clipId, snapshotAcquiredSerial);
1005
+ break;
1006
+ }
1007
+ removalRetryAtMs.delete(command.clipId);
1008
+ removalUnknownAtSerial.delete(command.clipId);
1009
+ owned.delete(command.clipId);
1010
+ removedIds.add(command.clipId);
1011
+ playingStartedMs.delete(command.clipId);
1012
+ if (item === undefined || item.phase === "Started" || item.phase === "Terminal")
1013
+ break;
1014
+ delete item.clipId;
1015
+ item.sessionId = undefined;
1016
+ const pending = pendingWithdrawals.get(command.key);
1017
+ if (pending !== undefined) {
1018
+ yield* emit(item, { _tag: "Dropped", reason: pending.reason });
1019
+ yield* finishWithdrawal(command.key, Result.succeed("withdrawn"));
1020
+ }
1021
+ else
1022
+ yield* emit(item, { _tag: "Accepted" });
1023
+ break;
1024
+ }
1025
+ case "Order":
1026
+ blockedMove = Result.isFailure(result) ? command.signature : undefined;
1027
+ break;
1028
+ case "PauseAutoplay":
1029
+ if (Result.isFailure(result)) {
1030
+ drainFailure = result.failure;
1031
+ const replies = drainReplies.splice(0);
1032
+ for (const reply of replies)
1033
+ yield* Deferred.fail(reply, result.failure);
1034
+ }
1035
+ break;
1036
+ }
1037
+ });
1038
+ // An accepted drain still owes air to every line not yet Ready, and to a
1039
+ // Ready line held for a future At anchor, which runway must reach. Filler
1040
+ // is the only material that can cover either, so it keeps playing and
1041
+ // refilling until nothing accepted can still leave the host frozen. An
1042
+ // Unknown item does not count: without a fenced source it can stay open
1043
+ // until the scheduler closes, and filler for it would never stop.
1044
+ const fillerHeld = (nowMs) => draining &&
1045
+ finishAccepted &&
1046
+ [...items.values()].some((item) => item.phase === "Accepted" ||
1047
+ item.phase === "Building" ||
1048
+ (item.phase === "Ready" && item.atMs !== undefined && item.atMs > nowMs));
1049
+ const drainPending = (state) => Effect.gen(function* () {
1050
+ if (!draining)
1051
+ return;
1052
+ const playingId = Option.getOrUndefined(state.playing)?.clipId;
1053
+ for (const item of items.values()) {
1054
+ if (finishAccepted)
1055
+ break;
1056
+ if (item.phase === "Terminal" || item.phase === "Started")
1057
+ continue;
1058
+ if (item.clipId === playingId)
1059
+ continue;
1060
+ yield* requestWithdrawal(item, "withdrawn");
1061
+ }
1062
+ if (fillerHeld(monotonicMillis(clock)))
1063
+ return;
1064
+ for (const entry of filler.values()) {
1065
+ if (entry.clipId !== undefined &&
1066
+ entry.clipId !== playingId &&
1067
+ !pendingFillerRemovals.has(entry.clipId) &&
1068
+ canRetryRemoval(entry.clipId)) {
1069
+ pendingFillerRemovals.add(entry.clipId);
1070
+ yield* sendCommand({ _tag: "RemoveFiller", clipId: entry.clipId });
1071
+ }
1072
+ }
1073
+ });
1074
+ const projectedStartMs = (state, lane, nowMs) => {
1075
+ const playing = Option.getOrUndefined(state.playing);
1076
+ const playingRecord = playing === undefined ? undefined : Option.getOrUndefined(playing.record);
1077
+ const started = playing === undefined ? undefined : playingStartedMs.get(playing.clipId);
1078
+ const restMs = playingRecord === undefined || started === undefined
1079
+ ? 0
1080
+ : Math.max(0, playingRecord.durationSeconds * 1000 - (nowMs - started));
1081
+ const laneRank = lanes.indexOf(lane);
1082
+ const preferred = Option.getOrUndefined(state.preferredSessionId);
1083
+ const aheadMs = state.ready.reduce((total, record) => {
1084
+ if (record.sessionId !== preferred)
1085
+ return total;
1086
+ const owner = owned.get(record.clipId);
1087
+ if (owner?._tag === "Filler")
1088
+ return total;
1089
+ if (owner?._tag === "Item") {
1090
+ const prior = items.get(owner.key);
1091
+ if (prior !== undefined && lanes.indexOf(prior.lane) > laneRank)
1092
+ return total;
1093
+ }
1094
+ return total + record.durationSeconds * 1000;
1095
+ }, 0);
1096
+ return nowMs + restMs + aheadMs;
1097
+ };
1098
+ const reconcile = Effect.gen(function* () {
1099
+ const state = yield* engine.state;
1100
+ prune(state);
1101
+ refreshAnchors();
1102
+ if (!observationReady) {
1103
+ yield* publishState(state);
1104
+ return;
1105
+ }
1106
+ if (unknownFiller !== undefined) {
1107
+ const uncertain = unknownFiller;
1108
+ const sourceRetired = uncertain.sessionId !== undefined &&
1109
+ !state.sessions.some((session) => session.sessionId === uncertain.sessionId);
1110
+ if (sourceRetired) {
1111
+ unknownFiller = undefined;
1112
+ fillerIndex = Math.max(fillerIndex, uncertain.index + 1);
1113
+ upcomingFiller = undefined;
1114
+ }
1115
+ else if (uncertain.sessionId !== undefined &&
1116
+ Option.getOrUndefined(state.preferredSessionId) !== uncertain.sessionId &&
1117
+ fillerIndex <= uncertain.index) {
1118
+ // A replacement may build independently, with a distinct key. The
1119
+ // uncertain old request can still be adopted if it later appears.
1120
+ fillerIndex = uncertain.index + 1;
1121
+ upcomingFiller = undefined;
1122
+ }
1123
+ }
1124
+ for (const [key, pending] of pendingWithdrawals) {
1125
+ const item = items.get(key);
1126
+ if (item === undefined)
1127
+ continue;
1128
+ if (item.phase === "Started")
1129
+ yield* finishWithdrawal(key, Result.succeed("already-started"));
1130
+ else if (item.clipId !== undefined)
1131
+ yield* sendItemRemoval(item, pending.reason);
1132
+ }
1133
+ for (const item of items.values())
1134
+ if (item.phase === "Unknown" &&
1135
+ item.clipId === undefined &&
1136
+ item.unknownSessionId !== undefined &&
1137
+ !state.sessions.some((session) => session.sessionId === item.unknownSessionId))
1138
+ yield* resolveRetiredUnknown(item);
1139
+ if (draining)
1140
+ yield* drainPending(state);
1141
+ const decision = plan({
1142
+ engine: state,
1143
+ items: [...items.values()],
1144
+ owned,
1145
+ lanes,
1146
+ nowMs: monotonicMillis(clock),
1147
+ playingStartedMs,
1148
+ floorSeconds,
1149
+ targetSeconds,
1150
+ refillActive,
1151
+ maxBuildsInFlight,
1152
+ accepting: accepting || (draining && finishAccepted),
1153
+ fillerEnabled: !draining || fillerHeld(monotonicMillis(clock)),
1154
+ fillerRetryAtMs,
1155
+ fillerUnknown: unknownFiller !== undefined,
1156
+ fillerUnknownSessionId: unknownFiller?.sessionId,
1157
+ blockedMove,
1158
+ });
1159
+ refillActive = decision.refillActive;
1160
+ yield* publishState(state);
1161
+ if ((!draining || finishAccepted) && commandCount === 0 && decision.action !== undefined)
1162
+ yield* applyPolicy(decision.action, state);
1163
+ if (draining &&
1164
+ finishAccepted &&
1165
+ !autoplayPaused &&
1166
+ commandCount === 0 &&
1167
+ [...items.values()].every((item) => item.phase === "Terminal")) {
1168
+ autoplayPaused = true;
1169
+ yield* sendCommand({ _tag: "PauseAutoplay" });
1170
+ }
1171
+ if (drainFailure === undefined &&
1172
+ drainReplies.length > 0 &&
1173
+ commandCount === 0 &&
1174
+ [...items.values()].every((item) => item.phase === "Terminal") &&
1175
+ pendingWithdrawals.size === 0 &&
1176
+ unknownFiller === undefined) {
1177
+ const after = yield* engine.state;
1178
+ if (Option.isNone(after.playing) && !activeIds(after).some((clipId) => owned.has(clipId))) {
1179
+ drained = true;
1180
+ const replies = drainReplies.splice(0);
1181
+ for (const reply of replies)
1182
+ yield* Deferred.succeed(reply, undefined);
1183
+ }
1184
+ }
1185
+ });
1186
+ const actor = Effect.gen(function* () {
1187
+ for (;;) {
1188
+ const message = yield* Queue.take(inbox);
1189
+ if (ended !== undefined && message._tag !== "CommandDone") {
1190
+ if (message._tag === "Submit")
1191
+ yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
1192
+ else if (message._tag === "Withdraw")
1193
+ yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
1194
+ else if (message._tag === "Drain")
1195
+ yield* Deferred.fail(message.reply, PolicyFailure.refuse("SessionClosed", ended.message));
1196
+ continue;
1197
+ }
1198
+ switch (message._tag) {
1199
+ case "Submit": {
1200
+ if (!accepting) {
1201
+ yield* Deferred.fail(message.reply, invalid("Scheduler is draining or closed"));
1202
+ break;
1203
+ }
1204
+ const existing = items.get(message.item.key) ?? history.get(message.item.key);
1205
+ if (existing !== undefined) {
1206
+ if (existing.fingerprint === message.item.fingerprint)
1207
+ yield* Deferred.succeed(message.reply, existing.handle);
1208
+ else
1209
+ yield* Deferred.fail(message.reply, KeyMismatch.of(message.item.key));
1210
+ break;
1211
+ }
1212
+ const nowMs = monotonicMillis(clock);
1213
+ const state = yield* engine.state;
1214
+ const startByMs = message.item.startByOffsetMs === undefined
1215
+ ? undefined
1216
+ : nowMs + message.item.startByOffsetMs;
1217
+ const projectedMs = projectedStartMs(state, message.item.lane, nowMs);
1218
+ if (startByMs !== undefined && projectedMs > startByMs) {
1219
+ yield* Deferred.fail(message.reply, WouldMissDeadline.of(message.item.key));
1220
+ break;
1221
+ }
1222
+ const startedWaiter = yield* Deferred.make();
1223
+ const outcomeWaiter = yield* Deferred.make();
1224
+ const firstDecisiveWaiter = yield* Deferred.make();
1225
+ const handle = {
1226
+ key: message.item.key,
1227
+ started: Deferred.await(startedWaiter),
1228
+ outcome: Deferred.await(outcomeWaiter),
1229
+ firstDecisive: Deferred.await(firstDecisiveWaiter),
1230
+ };
1231
+ const item = {
1232
+ key: message.item.key,
1233
+ lane: message.item.lane,
1234
+ request: message.item.request,
1235
+ fingerprint: message.item.fingerprint,
1236
+ admission: ++admission,
1237
+ phase: "Accepted",
1238
+ status: { _tag: "Accepted" },
1239
+ ...(message.item.notBeforeOffsetMs === undefined
1240
+ ? {}
1241
+ : { notBeforeMs: nowMs + message.item.notBeforeOffsetMs }),
1242
+ ...(startByMs === undefined ? {} : { startByMs }),
1243
+ firm: message.item.firm,
1244
+ ...(message.item.atWallMs === undefined
1245
+ ? {}
1246
+ : {
1247
+ atWallMs: message.item.atWallMs,
1248
+ atMs: nowMs + message.item.atWallMs - clock.currentTimeMillisUnsafe(),
1249
+ }),
1250
+ late: message.item.late,
1251
+ sessionId: undefined,
1252
+ unknownSessionId: undefined,
1253
+ unknownCause: undefined,
1254
+ handle,
1255
+ startedWaiter,
1256
+ outcomeWaiter,
1257
+ firstDecisiveWaiter,
1258
+ };
1259
+ items.set(item.key, item);
1260
+ yield* PubSub.publish(events, {
1261
+ key: item.key,
1262
+ at: clock.currentTimeMillisUnsafe(),
1263
+ status: item.status,
1264
+ });
1265
+ yield* observed(state);
1266
+ yield* Deferred.succeed(message.reply, handle);
1267
+ break;
1268
+ }
1269
+ case "Withdraw": {
1270
+ const item = items.get(message.key);
1271
+ if (item === undefined)
1272
+ yield* Deferred.succeed(message.reply, "not-found");
1273
+ else
1274
+ yield* requestWithdrawal(item, "withdrawn", message.reply);
1275
+ break;
1276
+ }
1277
+ case "Drain": {
1278
+ if (drainFailure !== undefined) {
1279
+ yield* Deferred.fail(message.reply, drainFailure);
1280
+ break;
1281
+ }
1282
+ accepting = false;
1283
+ if (drained) {
1284
+ yield* Deferred.succeed(message.reply, undefined);
1285
+ break;
1286
+ }
1287
+ drainReplies.push(message.reply);
1288
+ if (!draining) {
1289
+ draining = true;
1290
+ finishAccepted = message.finish === "accepted";
1291
+ // Renewal admission must stop even while an earlier enqueue is
1292
+ // awaiting its reply in the command worker.
1293
+ const stoppedRenewal = yield* Effect.result(engine.stopRenewal);
1294
+ if (Result.isFailure(stoppedRenewal)) {
1295
+ drainFailure = stoppedRenewal.failure;
1296
+ for (const reply of drainReplies.splice(0))
1297
+ yield* Deferred.fail(reply, stoppedRenewal.failure);
1298
+ break;
1299
+ }
1300
+ if (!finishAccepted) {
1301
+ autoplayPaused = true;
1302
+ yield* sendCommand({ _tag: "PauseAutoplay" });
1303
+ }
1304
+ }
1305
+ yield* drainPending(yield* engine.state);
1306
+ break;
1307
+ }
1308
+ case "Snapshot": {
1309
+ lastProcessedSnapshotSerial = message.acquiredSerial;
1310
+ observationReady = true;
1311
+ yield* observed(message.state, true);
1312
+ blockedMove = undefined;
1313
+ break;
1314
+ }
1315
+ case "Event":
1316
+ yield* onEvent(message.event);
1317
+ yield* observed(yield* engine.state);
1318
+ blockedMove = undefined;
1319
+ break;
1320
+ case "Tick":
1321
+ break;
1322
+ case "CommandDone":
1323
+ yield* handleCommandDone(message.command, message.result);
1324
+ if (commandCount === 0)
1325
+ earlyClipEvents.clear();
1326
+ break;
1327
+ case "Closed":
1328
+ yield* closeActor(message.cause);
1329
+ yield* Deferred.fail(initialized, ReactorError.fromCode("Closed", message.cause.message));
1330
+ break;
1331
+ }
1332
+ if (ended === undefined)
1333
+ yield* reconcile;
1334
+ if (message._tag === "Snapshot")
1335
+ yield* Deferred.succeed(initialized, undefined);
1336
+ }
1337
+ });
1338
+ const observe = Effect.gen(function* () {
1339
+ for (;;) {
1340
+ const result = yield* Effect.result(Effect.scoped(Effect.gen(function* () {
1341
+ // Claim the serial before acquiring the initial state. A snapshot
1342
+ // begun before an enqueue result must not disprove its clip.
1343
+ const acquiredSerial = ++snapshotAcquiredSerial;
1344
+ const observation = yield* engine.observe({ capacity: 4096 });
1345
+ yield* Queue.offer(inbox, {
1346
+ _tag: "Snapshot",
1347
+ state: observation.initial,
1348
+ acquiredSerial,
1349
+ });
1350
+ yield* Stream.runForEach(observation.events, (event) => Queue.offer(inbox, { _tag: "Event", event }));
1351
+ })));
1352
+ if (Result.isSuccess(result)) {
1353
+ yield* Queue.offer(inbox, {
1354
+ _tag: "Closed",
1355
+ cause: ReactorError.fromCode("Closed", "The orchestration closed"),
1356
+ });
1357
+ return;
1358
+ }
1359
+ if (result.failure.reason._tag !== "Overflow") {
1360
+ yield* Queue.offer(inbox, { _tag: "Closed", cause: result.failure });
1361
+ return;
1362
+ }
1363
+ }
1364
+ });
1365
+ // A defect remains a defect, but it must wake waiters just as a typed
1366
+ // terminal failure does. Scope interruption uses the finalizer below.
1367
+ const supervise = (effect) => effect.pipe(Effect.onExit((exit) => Exit.isFailure(exit) && ended === undefined
1368
+ ? Effect.gen(function* () {
1369
+ accepting = false;
1370
+ yield* Deferred.failCause(stopped, exit.cause);
1371
+ yield* Deferred.failCause(initialized, exit.cause);
1372
+ for (const item of items.values()) {
1373
+ yield* Deferred.failCause(item.startedWaiter, exit.cause);
1374
+ yield* Deferred.failCause(item.outcomeWaiter, exit.cause);
1375
+ yield* Deferred.failCause(item.firstDecisiveWaiter, exit.cause);
1376
+ }
1377
+ yield* Queue.shutdown(inbox);
1378
+ yield* Queue.shutdown(commandQueue);
1379
+ })
1380
+ : Effect.void));
1381
+ yield* Effect.forkScoped(supervise(commandWorker));
1382
+ yield* Effect.forkScoped(supervise(actor));
1383
+ yield* Effect.forkScoped(supervise(observe));
1384
+ yield* Effect.forkScoped(Effect.gen(function* () {
1385
+ for (;;) {
1386
+ yield* Effect.sleep("100 millis");
1387
+ yield* Queue.offer(inbox, { _tag: "Tick" });
1388
+ }
1389
+ }));
1390
+ yield* Queue.offer(inbox, { _tag: "Tick" });
1391
+ yield* Effect.addFinalizer(() => closeActor(ReactorError.fromCode("Closed", "The scheduler closed")));
1392
+ yield* Deferred.await(initialized);
1393
+ const submit = (input) => Effect.gen(function* () {
1394
+ if (yield* Deferred.isDone(stopped))
1395
+ return yield* closedCall;
1396
+ const item = yield* captureItem(input, new Set(lanes));
1397
+ yield* Effect.annotateCurrentSpan("reactor.scheduler.item.key", item.key);
1398
+ const reply = yield* Deferred.make();
1399
+ if (!(yield* Queue.offer(inbox, { _tag: "Submit", item, reply })))
1400
+ return yield* closedCall;
1401
+ return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
1402
+ }).pipe(Effect.withSpan("Scheduler.submit", {}, { captureStackTrace: false }));
1403
+ const withdraw = (key) => Effect.gen(function* () {
1404
+ const reply = yield* Deferred.make();
1405
+ if (!(yield* Queue.offer(inbox, { _tag: "Withdraw", key, reply })))
1406
+ return yield* closedCall;
1407
+ return yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
1408
+ }).pipe(Effect.withSpan("Scheduler.withdraw", { attributes: { key } }, {
1409
+ captureStackTrace: false,
1410
+ }));
1411
+ const drain = (options = {}) => Effect.gen(function* () {
1412
+ const reply = yield* Deferred.make();
1413
+ const finish = options.finish ?? "playing";
1414
+ if (finish !== "playing" && finish !== "accepted")
1415
+ return yield* invalid("Drain finish must be playing or accepted");
1416
+ if (!(yield* Queue.offer(inbox, { _tag: "Drain", finish, reply })))
1417
+ return yield* closedCall;
1418
+ yield* Deferred.await(reply).pipe(Effect.raceFirst(closedCall));
1419
+ }).pipe(Effect.withSpan("Scheduler.drain", {}, { captureStackTrace: false }));
1420
+ return {
1421
+ submit,
1422
+ withdraw,
1423
+ drain,
1424
+ failure: Deferred.await(stopped),
1425
+ state: SubscriptionRef.get(stateRef),
1426
+ asRun: Stream.fromPubSub(events),
1427
+ };
1428
+ });
1429
+ export const layerScheduler = (options) => Layer.effect(Scheduler, makeScheduler(options));
1430
+ //# sourceMappingURL=scheduler.js.map