effect-durable-agent-cloudflare 0.1.0-alpha.22 → 0.1.0-alpha.23

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.
@@ -1,6 +1,7 @@
1
1
  import "effect-durable-agent/services/prompt-projector";
2
2
  import * as Layer from "effect/Layer";
3
3
  import * as Effect from "effect/Effect";
4
+ import * as Exit from "effect/Exit";
4
5
  import * as Schema from "effect/Schema";
5
6
  import { ContextRebasedPayload, DurableEventEnvelope, SummaryCreatedPayload, assistantMessageCommittedEventType, assistantMessageImportedEventType, assistantPartialCommittedEventType, commandAdmittedEventType, commandCancelledEventType, commandCompletedEventType, commandFailedEventType, commandStartedEventType, contextRebasedEventType, decodeDurableEventSync, effectDurableAgentNamespace, steeringMessageQueuedEventType, summaryCreatedEventType, systemMessageCommittedEventType, userMessageCommittedEventType } from "effect-durable-agent/types/events";
6
7
  import { SequenceNumber, SessionId, SummaryId, durablePosition } from "effect-durable-agent/types/core";
@@ -20,7 +21,8 @@ import { EDAKeepAlive } from "effect-durable-agent/services/keep-alive";
20
21
  import { EDAReducerRegistry } from "effect-durable-agent/services/reducer-registry";
21
22
  import { makeEDARuntimeLayer } from "effect-durable-agent/services/runtime-layer";
22
23
  import { SessionEventObserver } from "effect-durable-agent/services/session-event-observer";
23
- import { EDAWebSocketDeliveryCheckpoint, EDA_WEB_SOCKET_PING_MESSAGE, EDA_WEB_SOCKET_PONG_MESSAGE, EDA_WS_CLOSE_PROTOCOL_ERROR, EDA_WS_CLOSE_SEND_FAILED, SubscriberId, checkpointWebSocketDeliveryState, decodeEDAWebSocketClientMessage, defaultEDAWebSocketFlowControl, deliveryHelloFrame, encodeEDAWebSocketServerFrame, makeWebSocketDeliveryState, onClientAck, onDurableEvents, onEphemeralEvent, onHostSuppressedFrame, restoreWebSocketDeliveryState } from "effect-durable-agent/websocket";
24
+ import { EDATranscriptClientFrame, EDATranscriptDeliveryCheckpoint, EDAWebSocketDeliveryCheckpoint, EDA_WEB_SOCKET_PING_MESSAGE, EDA_WEB_SOCKET_PONG_MESSAGE, EDA_WEB_SOCKET_PROTOCOL_VERSION, EDA_WS_CLOSE_LAGGED, EDA_WS_CLOSE_PROTOCOL_ERROR, EDA_WS_CLOSE_SEND_FAILED, EDA_WS_CLOSE_TRANSCRIPT_RESUBSCRIBE, SubscriberId, checkpointTranscriptDelivery, checkpointWebSocketDeliveryState, decodeEDAWebSocketClientMessage, defaultEDATranscriptDeliveryLimits, defaultEDAWebSocketFlowControl, deliveryHelloFrame, edaTranscriptNegotiation, edaTranscriptUpdateFrameText, encodeEDATranscriptServerFrame, encodeEDATranscriptUpdate, encodeEDAWebSocketServerFrame, enqueueTranscriptRequest, laggedCloseReason, makeTranscriptDeliveryState, makeWebSocketDeliveryState, onClientAck, onDurableEvents, onEphemeralEvent, onHostSuppressedFrame, onTranscriptAck, pumpTranscriptTransfer, restoreTranscriptDeliveryState, restoreWebSocketDeliveryState, startTranscriptUpdate, transcriptAckTimedOut, transcriptDeliveryCapacity, utf8ByteLength } from "effect-durable-agent/websocket";
25
+ import { defaultTranscriptSubscriptionPolicy, emptyTranscriptActiveContentState, foldTranscriptActiveContent, planTranscriptUpdate, readTranscriptSubscription, transcriptActiveContentOf, transcriptSubscriptionBaseOf } from "effect-durable-agent/transcript";
24
26
  /**
25
27
  * Alarm-backed in-memory keepalive for a raw Durable Object host.
26
28
  *
@@ -986,13 +988,27 @@ const EDAWebSocketAttachment = Schema.Struct({
986
988
  state: Schema.Unknown
987
989
  }))
988
990
  });
991
+ /**
992
+ * State required to resume a transcript-window socket after hibernation: the transport receipts,
993
+ * the applied cut, the subscription base and unanswered requests. Staged chunk data is never
994
+ * persisted; a checkpoint that names a transfer cannot resume it.
995
+ */
996
+ const EDATranscriptWebSocketAttachment = Schema.Struct({
997
+ kind: Schema.Literal("eda-transcript-v1"),
998
+ sessionId: SessionId,
999
+ subscriberId: SubscriberId,
1000
+ trace: EDATraceMetadata,
1001
+ delivery: EDATranscriptDeliveryCheckpoint
1002
+ });
1003
+ /** Either socket kind an EDA Durable Object accepts. */
1004
+ const EDAAnyWebSocketAttachment = Schema.Union([EDAWebSocketAttachment, EDATranscriptWebSocketAttachment]);
989
1005
  /** Decode and classify an attachment read from Cloudflare's structured-clone boundary. */
990
1006
  const decodeWebSocketAttachment = async (input) => {
991
1007
  if (input === null || input === void 0) return { _tag: "Missing" };
992
1008
  try {
993
1009
  return {
994
1010
  _tag: "Decoded",
995
- attachment: await Effect.runPromise(Schema.decodeUnknownEffect(EDAWebSocketAttachment)(input))
1011
+ attachment: await Effect.runPromise(Schema.decodeUnknownEffect(EDAAnyWebSocketAttachment)(input))
996
1012
  };
997
1013
  } catch {
998
1014
  return { _tag: "Malformed" };
@@ -1000,6 +1016,505 @@ const decodeWebSocketAttachment = async (input) => {
1000
1016
  };
1001
1017
  /** Encode before serialization so schema transformations cannot leak into attachments. */
1002
1018
  const encodeWebSocketAttachment = (attachment) => Schema.encodeSync(EDAWebSocketAttachment)(attachment);
1019
+ /** Encode a transcript socket attachment for Cloudflare's structured-clone boundary. */
1020
+ const encodeTranscriptWebSocketAttachment = (attachment) => Schema.encodeSync(EDATranscriptWebSocketAttachment)(attachment);
1021
+ //#endregion
1022
+ //#region src/websocket/transcript-sockets.ts
1023
+ const defaultActiveContentCadenceMs = 50;
1024
+ /** Largest client frame accepted; requests are persisted, so this bounds the attachment too. */
1025
+ const maxClientFrameLength = 2048;
1026
+ const decodeClientFrame = Schema.decodeUnknownExit(EDATranscriptClientFrame);
1027
+ /**
1028
+ * Owns transcript-window sockets: the window data source beneath the frame encoder.
1029
+ *
1030
+ * Every subscriber is served strictly in order. Committed changes are coalesced to the complete
1031
+ * boundary a store read returns; a request is answered by one read at one cut; and each update is
1032
+ * sent from its own captured snapshot before the next is planned.
1033
+ *
1034
+ * **Publication fence.** Append publication, ephemeral publication and window capture share one
1035
+ * fence: the active-content accumulator folds every published event in order, and a capture is
1036
+ * served with live content only when the accumulator has folded exactly the window's cut. Durable
1037
+ * commit precedes live publication, so a window read between the two would otherwise pair a
1038
+ * committed inference with its stale live parts. A capture that would cross the fence waits for
1039
+ * publication to catch up.
1040
+ */
1041
+ var EDATranscriptSockets = class {
1042
+ options;
1043
+ sockets = /* @__PURE__ */ new Map();
1044
+ active = emptyTranscriptActiveContentState;
1045
+ timerArmed = false;
1046
+ background = /* @__PURE__ */ new Set();
1047
+ flowControl;
1048
+ policy;
1049
+ limits;
1050
+ cadenceMs;
1051
+ fenceWaitMs;
1052
+ maxTotalStagedBytes;
1053
+ schedule;
1054
+ now;
1055
+ constructor(options) {
1056
+ this.options = options;
1057
+ this.flowControl = options.flowControl ?? defaultEDAWebSocketFlowControl;
1058
+ this.policy = options.subscriptionPolicy ?? defaultTranscriptSubscriptionPolicy;
1059
+ this.limits = options.deliveryLimits ?? defaultEDATranscriptDeliveryLimits;
1060
+ this.cadenceMs = options.activeContentCadenceMs ?? 50;
1061
+ this.fenceWaitMs = options.fenceWaitMs ?? 1e3;
1062
+ this.maxTotalStagedBytes = options.maxTotalStagedBytes ?? 33554432;
1063
+ this.schedule = options.schedule ?? ((delayMs, run) => {
1064
+ setTimeout(run, delayMs);
1065
+ });
1066
+ this.now = options.now ?? Date.now;
1067
+ }
1068
+ has(webSocket) {
1069
+ return this.sockets.has(webSocket);
1070
+ }
1071
+ /** Transcript sockets this isolate holds, for hosts that cannot enumerate accepted sockets. */
1072
+ webSockets() {
1073
+ return [...this.sockets.keys()];
1074
+ }
1075
+ /**
1076
+ * Register a freshly accepted socket and greet it. Synchronous so the attachment exists before
1077
+ * any platform callback can observe the socket.
1078
+ */
1079
+ accept(input) {
1080
+ const socket = {
1081
+ sessionId: input.sessionId,
1082
+ subscriberId: SubscriberId.make(crypto.randomUUID()),
1083
+ trace: input.trace,
1084
+ delivery: makeTranscriptDeliveryState(this.flowControl),
1085
+ queue: Promise.resolve(),
1086
+ dirty: false,
1087
+ sentActiveRevision: void 0,
1088
+ last: void 0,
1089
+ fenceWaitingSinceMs: void 0,
1090
+ scheduledDrain: void 0,
1091
+ stagedBytes: 0,
1092
+ activeSentAtMs: void 0,
1093
+ cadenceDue: false
1094
+ };
1095
+ this.sockets.set(input.webSocket, socket);
1096
+ if (!this.persist(input.webSocket, socket)) return;
1097
+ this.sendText(input.webSocket, encodeEDATranscriptServerFrame({
1098
+ _tag: "hello",
1099
+ protocolVersion: EDA_WEB_SOCKET_PROTOCOL_VERSION,
1100
+ subscriberId: socket.subscriberId,
1101
+ resumeSeq: 0,
1102
+ flowControl: this.flowControl,
1103
+ negotiation: edaTranscriptNegotiation
1104
+ }));
1105
+ }
1106
+ /**
1107
+ * Adopt a socket that survived hibernation. A socket that was mid-transfer lost its staged
1108
+ * chunks with the isolate: it is closed, and the client reconnects and resubscribes.
1109
+ */
1110
+ restore(webSocket, attachment) {
1111
+ const restored = restoreTranscriptDeliveryState(this.flowControl, attachment.delivery);
1112
+ if (restored._tag === "TransferLost") {
1113
+ this.close(webSocket, EDA_WS_CLOSE_TRANSCRIPT_RESUBSCRIBE, "transfer-lost");
1114
+ return false;
1115
+ }
1116
+ this.sockets.set(webSocket, {
1117
+ sessionId: attachment.sessionId,
1118
+ subscriberId: attachment.subscriberId,
1119
+ trace: attachment.trace,
1120
+ delivery: restored.state,
1121
+ queue: Promise.resolve(),
1122
+ dirty: true,
1123
+ sentActiveRevision: void 0,
1124
+ last: void 0,
1125
+ fenceWaitingSinceMs: void 0,
1126
+ scheduledDrain: void 0,
1127
+ stagedBytes: 0,
1128
+ activeSentAtMs: void 0,
1129
+ cadenceDue: false
1130
+ });
1131
+ return true;
1132
+ }
1133
+ closed(webSocket) {
1134
+ this.sockets.delete(webSocket);
1135
+ }
1136
+ /**
1137
+ * Observe one published event, in publication order. Folds it into the active-content
1138
+ * accumulator synchronously, then lets each listed subscriber act on it: a durable event
1139
+ * schedules a read at the new boundary; changed live content is coalesced to the display
1140
+ * cadence.
1141
+ *
1142
+ * Reads are scheduled, not awaited, so subscribers never hold the session's append gate. The
1143
+ * fence does not need the gate: every capture is checked against the accumulator's folded head
1144
+ * when its read resolves.
1145
+ */
1146
+ onPublished(event, webSockets) {
1147
+ const revision = this.active.revision;
1148
+ this.active = foldTranscriptActiveContent(this.active, event);
1149
+ if (event.event.durability !== "durable") {
1150
+ if (this.active.revision !== revision) this.armCadence();
1151
+ return;
1152
+ }
1153
+ for (const webSocket of webSockets) {
1154
+ const socket = this.sockets.get(webSocket);
1155
+ if (socket === void 0) continue;
1156
+ socket.dirty = true;
1157
+ this.track(this.drain(webSocket, socket));
1158
+ }
1159
+ }
1160
+ /** Serve sockets restored from hibernation after a publication was folded. */
1161
+ serveRestored(webSockets) {
1162
+ for (const webSocket of webSockets) {
1163
+ const socket = this.sockets.get(webSocket);
1164
+ if (socket === void 0) continue;
1165
+ socket.dirty = true;
1166
+ this.track(this.drain(webSocket, socket));
1167
+ }
1168
+ }
1169
+ /**
1170
+ * The session's log was destroyed: live content belongs to a log that no longer exists and
1171
+ * every subscription names its incarnation. Close transcript sockets so clients resubscribe.
1172
+ */
1173
+ reset() {
1174
+ this.active = emptyTranscriptActiveContentState;
1175
+ for (const webSocket of [...this.sockets.keys()]) this.close(webSocket, EDA_WS_CLOSE_TRANSCRIPT_RESUBSCRIBE, "session-reset");
1176
+ }
1177
+ async message(webSocket, message) {
1178
+ const socket = this.sockets.get(webSocket);
1179
+ if (socket === void 0) {
1180
+ this.close(webSocket, EDA_WS_CLOSE_PROTOCOL_ERROR, "protocol");
1181
+ return;
1182
+ }
1183
+ if (message === EDA_WEB_SOCKET_PING_MESSAGE) {
1184
+ this.sendText(webSocket, EDA_WEB_SOCKET_PONG_MESSAGE);
1185
+ return;
1186
+ }
1187
+ if (message.length > maxClientFrameLength) {
1188
+ this.protocolError(webSocket, "Client frame too large");
1189
+ return;
1190
+ }
1191
+ let json;
1192
+ try {
1193
+ json = JSON.parse(message);
1194
+ } catch {
1195
+ this.protocolError(webSocket, "Malformed client frame");
1196
+ return;
1197
+ }
1198
+ const decoded = decodeClientFrame(json);
1199
+ if (Exit.isFailure(decoded)) {
1200
+ this.protocolError(webSocket, "Unknown client frame");
1201
+ return;
1202
+ }
1203
+ const frame = decoded.value;
1204
+ if (frame._tag === "ping") return;
1205
+ await this.enqueue(webSocket, socket, async () => {
1206
+ if (frame._tag === "ack") {
1207
+ const acked = onTranscriptAck(socket.delivery, frame);
1208
+ if (acked._tag === "ProtocolError") {
1209
+ this.protocolError(webSocket, acked.message);
1210
+ return;
1211
+ }
1212
+ if (acked._tag === "Ignored") return;
1213
+ socket.delivery = acked.state;
1214
+ if (!this.persist(webSocket, socket)) return;
1215
+ } else {
1216
+ const queued = enqueueTranscriptRequest(socket.delivery, frame, this.limits);
1217
+ if (queued === void 0) {
1218
+ this.protocolError(webSocket, "Too many unanswered transcript requests");
1219
+ return;
1220
+ }
1221
+ socket.delivery = queued;
1222
+ if (!this.persist(webSocket, socket)) return;
1223
+ }
1224
+ await this.drainNow(webSocket, socket);
1225
+ });
1226
+ }
1227
+ /** Wait for scheduled reads and cadence flushes; for orderly shutdown and tests. */
1228
+ async settled() {
1229
+ while (this.background.size > 0) await Promise.all([...this.background]);
1230
+ }
1231
+ /**
1232
+ * Own scheduled subscriber work until it settles. Drains never reject: `enqueue` contains every
1233
+ * failure and closes the socket.
1234
+ */
1235
+ track(work) {
1236
+ const tracked = work.finally(() => {
1237
+ this.background.delete(tracked);
1238
+ });
1239
+ this.background.add(tracked);
1240
+ }
1241
+ armCadence() {
1242
+ if (this.timerArmed) return;
1243
+ this.timerArmed = true;
1244
+ this.schedule(this.cadenceMs, () => {
1245
+ this.timerArmed = false;
1246
+ for (const [webSocket, socket] of this.sockets) {
1247
+ socket.cadenceDue = true;
1248
+ this.track(this.drain(webSocket, socket));
1249
+ }
1250
+ });
1251
+ }
1252
+ /**
1253
+ * Schedule one drain, coalescing with a drain that is scheduled but not yet running. A drain
1254
+ * that is already running re-checks `dirty` before it returns, so nothing is missed.
1255
+ */
1256
+ drain(webSocket, socket) {
1257
+ if (socket.scheduledDrain !== void 0) return socket.scheduledDrain;
1258
+ const scheduled = this.enqueue(webSocket, socket, () => {
1259
+ socket.scheduledDrain = void 0;
1260
+ return this.drainNow(webSocket, socket);
1261
+ });
1262
+ socket.scheduledDrain = scheduled;
1263
+ return scheduled;
1264
+ }
1265
+ /** Send everything this subscriber is owed, one complete update at a time. */
1266
+ async drainNow(webSocket, socket) {
1267
+ for (;;) {
1268
+ if (this.sockets.get(webSocket) !== socket) return;
1269
+ const nowMs = this.now();
1270
+ if (transcriptAckTimedOut(socket.delivery, nowMs)) {
1271
+ this.sendText(webSocket, encodeEDATranscriptServerFrame({
1272
+ _tag: "lagged",
1273
+ resumeSeq: socket.delivery.appliedThroughSeq,
1274
+ reason: "ack-timeout"
1275
+ }));
1276
+ this.close(webSocket, EDA_WS_CLOSE_LAGGED, laggedCloseReason(socket.delivery.appliedThroughSeq));
1277
+ return;
1278
+ }
1279
+ if (socket.delivery.transfer !== void 0) {
1280
+ const pumped = pumpTranscriptTransfer(socket.delivery, nowMs);
1281
+ if (pumped.frames.length === 0) return;
1282
+ socket.delivery = pumped.state;
1283
+ if (pumped.state.transfer === void 0) socket.stagedBytes = 0;
1284
+ if (!this.persistAndSend(webSocket, socket, pumped.frames)) return;
1285
+ continue;
1286
+ }
1287
+ if (transcriptDeliveryCapacity(socket.delivery) <= 0) return;
1288
+ const request = socket.delivery.requests[0];
1289
+ const base = socket.delivery.subscription;
1290
+ if (request === void 0) {
1291
+ if (base === void 0) return;
1292
+ if (!socket.dirty) {
1293
+ if (socket.sentActiveRevision === this.active.revision) {
1294
+ socket.cadenceDue = false;
1295
+ return;
1296
+ }
1297
+ if (!(socket.cadenceDue || socket.activeSentAtMs === void 0 || nowMs - socket.activeSentAtMs >= this.cadenceMs)) {
1298
+ this.armCadence();
1299
+ return;
1300
+ }
1301
+ const sent = await this.sendActiveOnly(webSocket, socket);
1302
+ if (sent === "NeedsRead") {
1303
+ socket.dirty = true;
1304
+ continue;
1305
+ }
1306
+ if (sent === "Failed") return;
1307
+ continue;
1308
+ }
1309
+ }
1310
+ const subscriptionRequest = request === void 0 ? { _tag: "Refresh" } : toSubscriptionRequest(request);
1311
+ socket.dirty = false;
1312
+ const read = await Effect.runPromiseExit(readTranscriptSubscription(this.options.transcriptStore, base, subscriptionRequest, this.policy));
1313
+ if (this.sockets.get(webSocket) !== socket) return;
1314
+ if (Exit.isFailure(read)) {
1315
+ await Effect.runPromise(Effect.logError("EDA transcript window read failed", {
1316
+ cause: read.cause,
1317
+ subscriberId: socket.subscriberId
1318
+ }));
1319
+ this.close(webSocket, EDA_WS_CLOSE_SEND_FAILED, "read-failed");
1320
+ return;
1321
+ }
1322
+ const { window, patchBase } = read.value;
1323
+ const active = this.active;
1324
+ const fenced = !(window.readiness._tag === "Ready") || window.throughSeq === active.publishedThroughSeq || window.throughSeq > active.publishedThroughSeq && active.inference === void 0;
1325
+ if (!fenced) {
1326
+ socket.fenceWaitingSinceMs ??= nowMs;
1327
+ if (nowMs - socket.fenceWaitingSinceMs < this.fenceWaitMs) {
1328
+ socket.dirty = true;
1329
+ this.armCadence();
1330
+ return;
1331
+ }
1332
+ }
1333
+ socket.fenceWaitingSinceMs = void 0;
1334
+ const update = planTranscriptUpdate({
1335
+ patchBase,
1336
+ window,
1337
+ updateSeq: socket.delivery.nextUpdateSeq,
1338
+ activeContent: fenced ? transcriptActiveContentOf(active) : [],
1339
+ includeActiveContent: !fenced || socket.sentActiveRevision !== active.revision || window.readiness._tag === "Ready" && socket.last !== void 0 && socket.last.readiness._tag !== "Ready",
1340
+ ...request?.requestId === void 0 ? {} : { requestId: request.requestId }
1341
+ });
1342
+ const remaining = request === void 0 ? socket.delivery.requests : socket.delivery.requests.slice(1);
1343
+ if (request === void 0 && this.isNoop(socket, update)) continue;
1344
+ if (!await this.start(webSocket, socket, update, transcriptSubscriptionBaseOf(window), remaining)) return;
1345
+ socket.sentActiveRevision = active.revision;
1346
+ if (update.activeContent !== void 0) {
1347
+ socket.cadenceDue = false;
1348
+ socket.activeSentAtMs = this.now();
1349
+ }
1350
+ socket.last = {
1351
+ readiness: window.readiness,
1352
+ hasOlder: window.hasOlder,
1353
+ hasNewer: window.hasNewer,
1354
+ dependencyIds: update.dependencyIds
1355
+ };
1356
+ }
1357
+ }
1358
+ /**
1359
+ * Publish changed live content without a store read. Valid only while the accumulator has
1360
+ * folded exactly the subscriber's last cut; otherwise a commit is pending and a read is needed.
1361
+ */
1362
+ async sendActiveOnly(webSocket, socket) {
1363
+ const base = socket.delivery.subscription;
1364
+ const last = socket.last;
1365
+ const active = this.active;
1366
+ if (last !== void 0 && last.readiness._tag !== "Ready") {
1367
+ socket.sentActiveRevision = active.revision;
1368
+ return "Sent";
1369
+ }
1370
+ if (base === void 0 || last === void 0 || active.publishedThroughSeq !== base.throughSeq) return "NeedsRead";
1371
+ const update = {
1372
+ updateSeq: socket.delivery.nextUpdateSeq,
1373
+ mode: "patch",
1374
+ incarnation: base.incarnation,
1375
+ version: base.version,
1376
+ throughSeq: base.throughSeq,
1377
+ ...base.coverage === void 0 ? {} : { coverage: base.coverage },
1378
+ hasOlder: last.hasOlder,
1379
+ hasNewer: last.hasNewer,
1380
+ readiness: last.readiness,
1381
+ records: [],
1382
+ dependencies: [],
1383
+ dependencyIds: last.dependencyIds,
1384
+ activeContent: transcriptActiveContentOf(active)
1385
+ };
1386
+ if (!await this.start(webSocket, socket, update, base, socket.delivery.requests)) return "Failed";
1387
+ socket.sentActiveRevision = active.revision;
1388
+ socket.cadenceDue = false;
1389
+ socket.activeSentAtMs = this.now();
1390
+ return "Sent";
1391
+ }
1392
+ /** A refresh that changes nothing the client holds is not sent. */
1393
+ isNoop(socket, update) {
1394
+ const base = socket.delivery.subscription;
1395
+ const last = socket.last;
1396
+ return base !== void 0 && last !== void 0 && update.mode === "patch" && update.records.length === 0 && update.dependencies.length === 0 && update.activeContent === void 0 && update.throughSeq === base.throughSeq && update.hasOlder === last.hasOlder && update.hasNewer === last.hasNewer && JSON.stringify(update.coverage) === JSON.stringify(base.coverage) && JSON.stringify(update.readiness) === JSON.stringify(last.readiness);
1397
+ }
1398
+ async start(webSocket, socket, update, subscription, requests) {
1399
+ const encodedUpdate = encodeEDATranscriptUpdate(update);
1400
+ const bytes = utf8ByteLength(encodedUpdate);
1401
+ if (utf8ByteLength(edaTranscriptUpdateFrameText(socket.delivery.nextFrameId, encodedUpdate)) > this.flowControl.maxFrameBytes && this.stagedBytes() + bytes > this.maxTotalStagedBytes) {
1402
+ this.close(webSocket, EDA_WS_CLOSE_TRANSCRIPT_RESUBSCRIBE, "staging-budget");
1403
+ return false;
1404
+ }
1405
+ const started = startTranscriptUpdate({
1406
+ ...socket.delivery,
1407
+ requests
1408
+ }, {
1409
+ update,
1410
+ encodedUpdate,
1411
+ subscription
1412
+ }, this.limits, this.now());
1413
+ if (started._tag === "TooLarge") {
1414
+ await Effect.runPromise(Effect.logError("EDA transcript update exceeds the staging bound", {
1415
+ bytes: started.bytes,
1416
+ subscriberId: socket.subscriberId
1417
+ }));
1418
+ this.close(webSocket, EDA_WS_CLOSE_SEND_FAILED, "update-too-large");
1419
+ return false;
1420
+ }
1421
+ socket.delivery = started.state;
1422
+ socket.stagedBytes = started.state.transfer === void 0 ? 0 : bytes;
1423
+ return this.persistAndSend(webSocket, socket, started.frames);
1424
+ }
1425
+ /** Bytes of chunked updates still being transferred, across every socket of this object. */
1426
+ stagedBytes() {
1427
+ let total = 0;
1428
+ for (const socket of this.sockets.values()) total += socket.stagedBytes;
1429
+ return total;
1430
+ }
1431
+ /** Persist the checkpoint before any frame it accounts for becomes client-visible. */
1432
+ persistAndSend(webSocket, socket, frames) {
1433
+ if (!this.persist(webSocket, socket)) return false;
1434
+ for (const frame of frames) if (!this.sendText(webSocket, frame)) return false;
1435
+ return true;
1436
+ }
1437
+ persist(webSocket, socket) {
1438
+ try {
1439
+ webSocket.serializeAttachment(encodeTranscriptWebSocketAttachment(EDATranscriptWebSocketAttachment.make({
1440
+ kind: "eda-transcript-v1",
1441
+ sessionId: socket.sessionId,
1442
+ subscriberId: socket.subscriberId,
1443
+ trace: socket.trace,
1444
+ delivery: checkpointTranscriptDelivery(socket.delivery)
1445
+ })));
1446
+ return true;
1447
+ } catch (error) {
1448
+ Effect.runSync(Effect.logError("EDA transcript socket attachment failed", {
1449
+ error,
1450
+ subscriberId: socket.subscriberId
1451
+ }));
1452
+ this.close(webSocket, EDA_WS_CLOSE_SEND_FAILED, "attachment-failed");
1453
+ return false;
1454
+ }
1455
+ }
1456
+ sendText(webSocket, text) {
1457
+ try {
1458
+ webSocket.send(text);
1459
+ return true;
1460
+ } catch (error) {
1461
+ Effect.runSync(Effect.logWarning("EDA transcript socket send failed", { error }));
1462
+ this.close(webSocket, EDA_WS_CLOSE_SEND_FAILED, "send-failed");
1463
+ return false;
1464
+ }
1465
+ }
1466
+ protocolError(webSocket, message) {
1467
+ this.sendText(webSocket, encodeEDATranscriptServerFrame({
1468
+ _tag: "error",
1469
+ message: message.slice(0, 120)
1470
+ }));
1471
+ this.close(webSocket, EDA_WS_CLOSE_PROTOCOL_ERROR, "protocol");
1472
+ }
1473
+ enqueue(webSocket, socket, operation) {
1474
+ const run = async () => {
1475
+ try {
1476
+ await operation();
1477
+ } catch (error) {
1478
+ await Effect.runPromise(Effect.logError("EDA transcript socket operation failed", {
1479
+ error,
1480
+ subscriberId: socket.subscriberId
1481
+ }));
1482
+ this.close(webSocket, EDA_WS_CLOSE_SEND_FAILED, "subscriber-failed");
1483
+ }
1484
+ };
1485
+ const next = socket.queue.then(run);
1486
+ socket.queue = next;
1487
+ return next;
1488
+ }
1489
+ close(webSocket, code, reason) {
1490
+ this.sockets.delete(webSocket);
1491
+ try {
1492
+ webSocket.close(code, reason);
1493
+ } catch {}
1494
+ }
1495
+ };
1496
+ const toSubscriptionRequest = (frame) => {
1497
+ switch (frame._tag) {
1498
+ case "subscribe": return {
1499
+ _tag: "Subscribe",
1500
+ tail: frame.tail ?? false,
1501
+ ...frame.range === void 0 ? {} : { range: frame.range },
1502
+ ...frame.since === void 0 ? {} : { since: frame.since },
1503
+ ...frame.count === void 0 ? {} : { count: frame.count }
1504
+ };
1505
+ case "page": return {
1506
+ _tag: "Page",
1507
+ direction: frame.direction,
1508
+ cursor: frame.cursor,
1509
+ count: frame.count
1510
+ };
1511
+ case "retain": return {
1512
+ _tag: "Retain",
1513
+ from: frame.from,
1514
+ to: frame.to
1515
+ };
1516
+ }
1517
+ };
1003
1518
  //#endregion
1004
1519
  //#region src/websocket/connection-manager.ts
1005
1520
  /**
@@ -1010,8 +1525,33 @@ var EDAWebSocketConnectionManager = class {
1010
1525
  options;
1011
1526
  observerLayer = SessionEventObserver.FromHandler((event) => Effect.tryPromise(() => this.fanoutPublishedEvent(event)).pipe(Effect.catchCause((cause) => Effect.logError("EDA WebSocket event observer recovered from a host failure", { cause }))));
1012
1527
  sockets = /* @__PURE__ */ new Map();
1528
+ transcript;
1013
1529
  constructor(options) {
1014
1530
  this.options = options;
1531
+ this.transcript = options.transcript === void 0 ? void 0 : new EDATranscriptSockets(options.transcript);
1532
+ }
1533
+ /** Whether this host serves transcript-window sockets. */
1534
+ get supportsTranscript() {
1535
+ return this.transcript !== void 0;
1536
+ }
1537
+ /**
1538
+ * Accept a transcript-window socket. Reads are storage-level, so this never prepares the
1539
+ * session runtime. Call immediately after `ctx.acceptWebSocket()`.
1540
+ */
1541
+ acceptTranscript(input) {
1542
+ if (this.transcript === void 0) {
1543
+ this.close(input.webSocket, EDA_WS_CLOSE_PROTOCOL_ERROR, "unsupported");
1544
+ return;
1545
+ }
1546
+ this.transcript.accept(input);
1547
+ }
1548
+ /** The session's log was destroyed: close transcript sockets and drop live content. */
1549
+ resetTranscript() {
1550
+ this.transcript?.reset();
1551
+ }
1552
+ /** Wait for scheduled transcript flushes; for orderly shutdown and tests. */
1553
+ async settled() {
1554
+ await this.transcript?.settled();
1015
1555
  }
1016
1556
  resolveProjection(id) {
1017
1557
  if (id === void 0) return void 0;
@@ -1055,7 +1595,13 @@ var EDAWebSocketConnectionManager = class {
1055
1595
  }
1056
1596
  return;
1057
1597
  }
1058
- const state = await this.restore(webSocket);
1598
+ const restored = await this.restore(webSocket);
1599
+ if (restored === "transcript") {
1600
+ await this.transcript?.message(webSocket, message);
1601
+ return;
1602
+ }
1603
+ if (restored === "closed") return;
1604
+ const state = restored;
1059
1605
  if (state === void 0) {
1060
1606
  this.close(webSocket, EDA_WS_CLOSE_PROTOCOL_ERROR, "protocol");
1061
1607
  return;
@@ -1074,6 +1620,7 @@ var EDAWebSocketConnectionManager = class {
1074
1620
  }
1075
1621
  closed(webSocket) {
1076
1622
  this.sockets.delete(webSocket);
1623
+ this.transcript?.closed(webSocket);
1077
1624
  }
1078
1625
  async flushDeferredCatchUp(sessionId) {
1079
1626
  const deliveries = [];
@@ -1088,11 +1635,22 @@ var EDAWebSocketConnectionManager = class {
1088
1635
  await Promise.all(deliveries);
1089
1636
  }
1090
1637
  async fanoutPublishedEvent(event) {
1091
- const webSockets = /* @__PURE__ */ new Set([...this.options.getWebSockets?.() ?? [], ...this.sockets.keys()]);
1638
+ const heldTranscriptSockets = new Set(this.transcript?.webSockets() ?? []);
1639
+ this.transcript?.onPublished(event, [...heldTranscriptSockets]);
1640
+ const webSockets = /* @__PURE__ */ new Set([
1641
+ ...this.options.getWebSockets?.() ?? [],
1642
+ ...this.sockets.keys(),
1643
+ ...this.transcript?.webSockets() ?? []
1644
+ ]);
1092
1645
  const deliveries = [];
1646
+ const transcriptSockets = [];
1093
1647
  for (const webSocket of webSockets) {
1094
1648
  const state = await this.restore(webSocket);
1095
- if (state === void 0) continue;
1649
+ if (state === "transcript") {
1650
+ transcriptSockets.push(webSocket);
1651
+ continue;
1652
+ }
1653
+ if (state === void 0 || state === "closed") continue;
1096
1654
  deliveries.push(this.enqueue(webSocket, state, async () => {
1097
1655
  if (event.event.durability !== "durable") {
1098
1656
  await this.interpret(webSocket, state, onEphemeralEvent(state.delivery, event, Date.now()));
@@ -1111,16 +1669,28 @@ var EDAWebSocketConnectionManager = class {
1111
1669
  await this.readAndDeliver(webSocket, state, state.delivery.sentDurableThroughSeq);
1112
1670
  }));
1113
1671
  }
1672
+ this.transcript?.serveRestored(transcriptSockets.filter((webSocket) => !heldTranscriptSockets.has(webSocket)));
1114
1673
  await Promise.all(deliveries);
1115
1674
  }
1116
1675
  async restore(webSocket) {
1117
1676
  const existing = this.sockets.get(webSocket);
1118
1677
  if (existing !== void 0) return existing;
1678
+ if (this.transcript?.has(webSocket) === true) return "transcript";
1119
1679
  const decoded = await decodeWebSocketAttachment(webSocket.deserializeAttachment());
1120
1680
  if (decoded._tag !== "Decoded") {
1121
1681
  this.close(webSocket, EDA_WS_CLOSE_PROTOCOL_ERROR, "protocol");
1122
1682
  return;
1123
1683
  }
1684
+ const restoredMeanwhile = this.sockets.get(webSocket);
1685
+ if (restoredMeanwhile !== void 0) return restoredMeanwhile;
1686
+ if (this.transcript?.has(webSocket) === true) return "transcript";
1687
+ if (decoded.attachment.kind === "eda-transcript-v1") {
1688
+ if (this.transcript === void 0) {
1689
+ this.close(webSocket, EDA_WS_CLOSE_PROTOCOL_ERROR, "protocol");
1690
+ return;
1691
+ }
1692
+ return this.transcript.restore(webSocket, decoded.attachment) ? "transcript" : "closed";
1693
+ }
1124
1694
  const attachment = decoded.attachment;
1125
1695
  const projectionState = attachment.projection === void 0 ? void 0 : await this.decodeProjectionState(attachment.projection.id, attachment.projection.state);
1126
1696
  if (attachment.projection !== void 0 && projectionState === void 0) {
@@ -1328,7 +1898,8 @@ var EDASessionController = class {
1328
1898
  "eda.seq.after": afterSeq
1329
1899
  })))),
1330
1900
  webSocketProjection: options.webSocketProjection,
1331
- webSocketProtocol: options.webSocketProtocol
1901
+ webSocketProtocol: options.webSocketProtocol,
1902
+ ...options.transcriptSockets === void 0 ? {} : { transcript: options.transcriptSockets }
1332
1903
  });
1333
1904
  this.runtime = new EDASessionRuntime({
1334
1905
  ...options,
@@ -1415,6 +1986,21 @@ var EDASessionController = class {
1415
1986
  acceptPreparedEventWebSocket(input) {
1416
1987
  return this.webSockets.accept(input);
1417
1988
  }
1989
+ /** Whether transcript-window sockets are served. */
1990
+ get supportsTranscriptWebSocket() {
1991
+ return this.webSockets.supportsTranscript;
1992
+ }
1993
+ /**
1994
+ * Register a transcript-window socket right after the host accepted it. Never prepares the
1995
+ * model runtime: windows are storage-level reads.
1996
+ */
1997
+ acceptTranscriptWebSocket(input) {
1998
+ this.webSockets.acceptTranscript({
1999
+ sessionId: input.sessionId,
2000
+ trace: input.trace ?? makeRootEDATraceMetadata(),
2001
+ webSocket: input.webSocket
2002
+ });
2003
+ }
1418
2004
  webSocketMessage(webSocket, message) {
1419
2005
  return this.webSockets.message(webSocket, message);
1420
2006
  }
@@ -1426,6 +2012,7 @@ var EDASessionController = class {
1426
2012
  }
1427
2013
  async destroy(_input) {
1428
2014
  await this.runtime.destroy();
2015
+ this.webSockets.resetTranscript();
1429
2016
  }
1430
2017
  async alarm(input) {
1431
2018
  if (input !== void 0) await this.prepareSession(input.sessionId);
@@ -1460,6 +2047,6 @@ const hostSpanOptions = (trace, attributes) => ({
1460
2047
  ...trace.parent === null ? {} : { parent: toEdaExternalSpan(trace.parent) }
1461
2048
  });
1462
2049
  //#endregion
1463
- export { EDAWebSocketProjectionId as n, makeEDADurableObjectRuntimeLayer as r, EDASessionController as t };
2050
+ export { makeEDADurableObjectRuntimeLayer as i, defaultActiveContentCadenceMs as n, EDAWebSocketProjectionId as r, EDASessionController as t };
1464
2051
 
1465
- //# sourceMappingURL=session-controller-BGfz1DdS.js.map
2052
+ //# sourceMappingURL=session-controller-BnNMF12R.js.map