@frockbot/cloudflare 0.0.0 → 0.7.291
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.
- package/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- package/README.md +0 -3
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StateFrame,
|
|
3
|
+
ConversationProjection,
|
|
4
|
+
} from "@frockbot/core/protocol-schemas";
|
|
5
|
+
import {
|
|
6
|
+
STATE_ASSEMBLED_MAX_BYTES,
|
|
7
|
+
STATE_FRAME_MAX_BYTES,
|
|
8
|
+
STATE_PART_MAX_BYTES,
|
|
9
|
+
} from "@frockbot/core/protocol-schemas";
|
|
10
|
+
import { decodeBotStateCursorV1 } from "@frockbot/core/protocol";
|
|
11
|
+
import {
|
|
12
|
+
commitPublicationsV1,
|
|
13
|
+
COMPUTER_ENTITY_ID_V1,
|
|
14
|
+
drainPendingPublicationV1,
|
|
15
|
+
LOOK_ENTITY_ID_V1,
|
|
16
|
+
PUBLICATION_REPLAY_MAX_EVENTS_V1,
|
|
17
|
+
readPublicationHeadV1,
|
|
18
|
+
readReplayUpdatesV1,
|
|
19
|
+
type ConversationUpdateV1,
|
|
20
|
+
type PublicationHeadV1,
|
|
21
|
+
} from "@frockbot/core/durable";
|
|
22
|
+
import { readConversationSnapshotV1 } from "@frockbot/app/shell/conversation-snapshot";
|
|
23
|
+
import type { ReplyDraftV1 } from "@frockbot/app/shell/reply-draft";
|
|
24
|
+
import {
|
|
25
|
+
RUN_ATTACHMENTS_PROTOCOL_V1,
|
|
26
|
+
withoutRunAttachmentsV1,
|
|
27
|
+
} from "@frockbot/app/shell/run-protocol";
|
|
28
|
+
import type {
|
|
29
|
+
ComputerBotStorage,
|
|
30
|
+
ComputerBotTransaction,
|
|
31
|
+
} from "@frockbot/computer/bot";
|
|
32
|
+
|
|
33
|
+
const CHANNEL_TAG = "bot-state-v1";
|
|
34
|
+
/**
|
|
35
|
+
* Sockets that asked for reply drafts with `drafts=1`. An observer built
|
|
36
|
+
* before drafts existed decodes every frame against a schema with no
|
|
37
|
+
* `state/draft` in it and would drop its socket on the first one, so a draft
|
|
38
|
+
* goes only where it was asked for.
|
|
39
|
+
*/
|
|
40
|
+
const DRAFT_TAG = "bot-state-v1-drafts";
|
|
41
|
+
export const BOT_STATE_CHANNEL_INTERNAL_PATH = "/internal/bot-state-channel/v1";
|
|
42
|
+
export const BOT_STATE_CHANNEL_RETENTION = PUBLICATION_REPLAY_MAX_EVENTS_V1;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The shortest gap between two Computer invalidations. Conversation frames
|
|
46
|
+
* are not coalesced: a committed send is worth an immediate observer write.
|
|
47
|
+
*/
|
|
48
|
+
export const BOT_STATE_RUNS_NOTICE_INTERVAL_MS = 250;
|
|
49
|
+
|
|
50
|
+
export interface BotStateChannelOptionsV1 {
|
|
51
|
+
/** Overridden only by tests, which cannot wait a real quarter of a second. */
|
|
52
|
+
runsNoticeIntervalMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function delay(ms: number): Promise<void> {
|
|
56
|
+
return new Promise<void>((resolve) => {
|
|
57
|
+
setTimeout(resolve, ms);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ChannelAttachmentV1 {
|
|
62
|
+
schemaVersion: 1;
|
|
63
|
+
userId: string;
|
|
64
|
+
botId: string;
|
|
65
|
+
epoch: string;
|
|
66
|
+
lastSent: string;
|
|
67
|
+
/**
|
|
68
|
+
* The client protocol the socket named when it opened. Absent on a socket
|
|
69
|
+
* opened before this was recorded, and on any client that names none: both
|
|
70
|
+
* are read as the oldest protocol this deployment serves.
|
|
71
|
+
*/
|
|
72
|
+
protocol?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The first client protocol that knows a `look` update. An older client
|
|
77
|
+
* decodes every frame against a schema without it and would drop its socket.
|
|
78
|
+
*/
|
|
79
|
+
export const LOOK_NOTICE_PROTOCOL_V1 = 4;
|
|
80
|
+
|
|
81
|
+
/** Whether a socket is sent a Run's files, or a Run without them. */
|
|
82
|
+
function currentProtocol(attachment: ChannelAttachmentV1): boolean {
|
|
83
|
+
return (attachment.protocol ?? 2) >= RUN_ATTACHMENTS_PROTOCOL_V1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isLookUpdate(frame: CursoredFrameV1): boolean {
|
|
87
|
+
return frame.type === "state/update" && frame.kind === "look";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The frames for one socket's protocol, stripped once and only if needed. */
|
|
91
|
+
class ProtocolFrames {
|
|
92
|
+
private readonly frame: CursoredFrameV1;
|
|
93
|
+
private readonly current: string[];
|
|
94
|
+
private legacy: string[] | undefined;
|
|
95
|
+
constructor(frame: CursoredFrameV1) {
|
|
96
|
+
this.frame = frame;
|
|
97
|
+
this.current = framesFor(frame);
|
|
98
|
+
}
|
|
99
|
+
get type(): CursoredFrameV1["type"] {
|
|
100
|
+
return this.frame.type;
|
|
101
|
+
}
|
|
102
|
+
get bytes(): number {
|
|
103
|
+
return frameBytes(this.frame);
|
|
104
|
+
}
|
|
105
|
+
for(attachment: ChannelAttachmentV1): string[] {
|
|
106
|
+
// The cursor still advances past it: the update is an invalidation, and
|
|
107
|
+
// a client that cannot read it reads the look when it next opens the Bot.
|
|
108
|
+
if (
|
|
109
|
+
isLookUpdate(this.frame) &&
|
|
110
|
+
(attachment.protocol ?? 2) < LOOK_NOTICE_PROTOCOL_V1
|
|
111
|
+
) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
if (currentProtocol(attachment)) return this.current;
|
|
115
|
+
this.legacy ??= framesFor(withoutRunAttachmentsV1(this.frame));
|
|
116
|
+
return this.legacy;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const utf8 = new TextEncoder();
|
|
121
|
+
const utf8Decoder = new TextDecoder();
|
|
122
|
+
|
|
123
|
+
function frameBytes(frame: CursoredFrameV1): number {
|
|
124
|
+
return utf8.encode(JSON.stringify(frame)).length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function encodeFrame(frame: StateFrame): string {
|
|
128
|
+
return JSON.stringify(frame);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function splitUtf8(text: string, maxBytes: number): string[] {
|
|
132
|
+
const bytes = utf8.encode(text);
|
|
133
|
+
if (bytes.length <= maxBytes) return [text];
|
|
134
|
+
const parts: string[] = [];
|
|
135
|
+
for (let offset = 0; offset < bytes.length;) {
|
|
136
|
+
let end = Math.min(offset + maxBytes, bytes.length);
|
|
137
|
+
if (end < bytes.length) {
|
|
138
|
+
while (end > offset && (bytes[end]! & 0xc0) === 0x80) end -= 1;
|
|
139
|
+
}
|
|
140
|
+
if (end <= offset) end = Math.min(offset + maxBytes, bytes.length);
|
|
141
|
+
parts.push(utf8Decoder.decode(bytes.subarray(offset, end)));
|
|
142
|
+
offset = end;
|
|
143
|
+
}
|
|
144
|
+
return parts;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A frame on the publication's cursor: every kind but a draft. */
|
|
148
|
+
type CursoredFrameV1 = Exclude<StateFrame, { type: "state/draft" }>;
|
|
149
|
+
|
|
150
|
+
function framesFor(frame: CursoredFrameV1): string[] {
|
|
151
|
+
const encoded = encodeFrame(frame);
|
|
152
|
+
if (utf8.encode(encoded).length <= STATE_FRAME_MAX_BYTES) return [encoded];
|
|
153
|
+
if (frame.type === "state/ready" || frame.type === "state/part") {
|
|
154
|
+
throw new Error("Bot-state frame exceeds the bound");
|
|
155
|
+
}
|
|
156
|
+
const chunks = splitUtf8(encoded, STATE_PART_MAX_BYTES);
|
|
157
|
+
if (chunks.length > 256) {
|
|
158
|
+
throw new Error("Bot-state frame exceeds the assembled bound");
|
|
159
|
+
}
|
|
160
|
+
let assembled = 0;
|
|
161
|
+
const parts: string[] = [];
|
|
162
|
+
for (const [index, data] of chunks.entries()) {
|
|
163
|
+
assembled += utf8.encode(data).length;
|
|
164
|
+
if (assembled > STATE_ASSEMBLED_MAX_BYTES) {
|
|
165
|
+
throw new Error("Bot-state frame exceeds the assembled bound");
|
|
166
|
+
}
|
|
167
|
+
parts.push(
|
|
168
|
+
encodeFrame({
|
|
169
|
+
schemaVersion: 1,
|
|
170
|
+
type: "state/part",
|
|
171
|
+
epoch: frame.epoch,
|
|
172
|
+
cursor: frame.cursor,
|
|
173
|
+
eventId: `${frame.epoch}:${frame.cursor}`,
|
|
174
|
+
part: index,
|
|
175
|
+
parts: chunks.length,
|
|
176
|
+
data,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return parts;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type HandshakeReasonV1 =
|
|
184
|
+
"initial" | "gap" | "cursor-ahead" | "epoch" | "replay";
|
|
185
|
+
|
|
186
|
+
/** Which handshake to send for a presented epoch/cursor. */
|
|
187
|
+
export function planHandshakeV1(
|
|
188
|
+
head: PublicationHeadV1,
|
|
189
|
+
cursor: number | undefined,
|
|
190
|
+
epoch: number | undefined,
|
|
191
|
+
): HandshakeReasonV1 {
|
|
192
|
+
if (cursor === undefined) return "initial";
|
|
193
|
+
if (epoch !== head.epoch) return "epoch";
|
|
194
|
+
if (cursor > head.lastCursor) return "cursor-ahead";
|
|
195
|
+
if (cursor < head.firstRetainedCursor - 1) return "gap";
|
|
196
|
+
return "replay";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function updateFrame(update: ConversationUpdateV1): CursoredFrameV1 {
|
|
200
|
+
return {
|
|
201
|
+
schemaVersion: 1,
|
|
202
|
+
type: "state/update",
|
|
203
|
+
epoch: String(update.epoch),
|
|
204
|
+
cursor: String(update.cursor),
|
|
205
|
+
kind: update.kind,
|
|
206
|
+
entityId: update.entityId,
|
|
207
|
+
revision: update.revision,
|
|
208
|
+
payload: (update.payload ?? {}) as StateFrame extends {
|
|
209
|
+
type: "state/update";
|
|
210
|
+
payload: infer Payload;
|
|
211
|
+
}
|
|
212
|
+
? Payload
|
|
213
|
+
: never,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function decodeAttachment(value: unknown): ChannelAttachmentV1 | undefined {
|
|
218
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
const attachment = value as ChannelAttachmentV1;
|
|
222
|
+
const keys = Object.keys(value).length;
|
|
223
|
+
if (
|
|
224
|
+
keys !== (attachment.protocol === undefined ? 5 : 6) ||
|
|
225
|
+
attachment.schemaVersion !== 1 ||
|
|
226
|
+
typeof attachment.userId !== "string" ||
|
|
227
|
+
!attachment.userId ||
|
|
228
|
+
typeof attachment.botId !== "string" ||
|
|
229
|
+
!attachment.botId ||
|
|
230
|
+
(attachment.protocol !== undefined &&
|
|
231
|
+
!Number.isSafeInteger(attachment.protocol))
|
|
232
|
+
) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
decodeBotStateCursorV1(attachment.lastSent);
|
|
237
|
+
decodeBotStateCursorV1(attachment.epoch);
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
return attachment;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A storage facade which appends a Computer invalidation and asks the Bot
|
|
246
|
+
* authority to recompute its one alarm in the same transaction as each
|
|
247
|
+
* authoritative Computer write. The append and schedule are durable;
|
|
248
|
+
* delivery to attached observers happens only after that transaction commits.
|
|
249
|
+
*/
|
|
250
|
+
class ChannelComputerStorage implements ComputerBotStorage {
|
|
251
|
+
constructor(
|
|
252
|
+
private readonly channel: BotStateChannel,
|
|
253
|
+
private readonly storage: DurableObjectStorage,
|
|
254
|
+
) {}
|
|
255
|
+
|
|
256
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
257
|
+
return this.storage.get<T>(key);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
261
|
+
put(entries: Record<string, unknown>): Promise<void>;
|
|
262
|
+
async put<T>(
|
|
263
|
+
keyOrEntries: string | Record<string, unknown>,
|
|
264
|
+
value?: T,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
await this.storage.transaction(async (transaction) => {
|
|
267
|
+
if (typeof keyOrEntries === "string") {
|
|
268
|
+
await transaction.put(keyOrEntries, value);
|
|
269
|
+
} else {
|
|
270
|
+
await transaction.put(keyOrEntries);
|
|
271
|
+
}
|
|
272
|
+
await this.channel.commitComputer(transaction);
|
|
273
|
+
await this.channel.refreshAlarm(transaction);
|
|
274
|
+
});
|
|
275
|
+
await this.channel.drainBroadcast();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async delete(key: string): Promise<boolean> {
|
|
279
|
+
let deleted = false;
|
|
280
|
+
await this.storage.transaction(async (transaction) => {
|
|
281
|
+
deleted = await transaction.delete(key);
|
|
282
|
+
if (deleted) {
|
|
283
|
+
await this.channel.commitComputer(transaction);
|
|
284
|
+
await this.channel.refreshAlarm(transaction);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
if (deleted) await this.channel.drainBroadcast();
|
|
288
|
+
return deleted;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async transaction<T>(
|
|
292
|
+
callback: (storage: ComputerBotTransaction) => Promise<T>,
|
|
293
|
+
): Promise<T> {
|
|
294
|
+
let changed = false;
|
|
295
|
+
const result = await this.storage.transaction(async (transaction) => {
|
|
296
|
+
const wrapped: ComputerBotTransaction = {
|
|
297
|
+
get: <Value>(key: string) => transaction.get<Value>(key),
|
|
298
|
+
put: async <Value>(
|
|
299
|
+
keyOrEntries: string | Record<string, unknown>,
|
|
300
|
+
value?: Value,
|
|
301
|
+
) => {
|
|
302
|
+
changed = true;
|
|
303
|
+
if (typeof keyOrEntries === "string") {
|
|
304
|
+
await transaction.put(keyOrEntries, value);
|
|
305
|
+
} else {
|
|
306
|
+
await transaction.put(keyOrEntries);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
delete: async (key: string) => {
|
|
310
|
+
const result = await transaction.delete(key);
|
|
311
|
+
changed ||= result;
|
|
312
|
+
return result;
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
const value = await callback(wrapped);
|
|
316
|
+
if (changed) {
|
|
317
|
+
await this.channel.commitComputer(transaction);
|
|
318
|
+
await this.channel.refreshAlarm(transaction);
|
|
319
|
+
}
|
|
320
|
+
return value;
|
|
321
|
+
});
|
|
322
|
+
if (changed) await this.channel.drainBroadcast();
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export class BotStateChannel {
|
|
328
|
+
readonly computerStorage: ComputerBotStorage;
|
|
329
|
+
private alarmRefresher:
|
|
330
|
+
((transaction: DurableObjectTransaction) => Promise<void>) | undefined;
|
|
331
|
+
private computerNotice: Promise<void> | undefined;
|
|
332
|
+
private computerPending = false;
|
|
333
|
+
private computerNoticeAt = 0;
|
|
334
|
+
private readonly runsNoticeIntervalMs: number;
|
|
335
|
+
private silenced = false;
|
|
336
|
+
|
|
337
|
+
constructor(
|
|
338
|
+
private readonly state: DurableObjectState,
|
|
339
|
+
options: BotStateChannelOptionsV1 = {},
|
|
340
|
+
) {
|
|
341
|
+
this.computerStorage = new ChannelComputerStorage(this, state.storage);
|
|
342
|
+
this.runsNoticeIntervalMs =
|
|
343
|
+
options.runsNoticeIntervalMs ?? BOT_STATE_RUNS_NOTICE_INTERVAL_MS;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The kernel's own alarm refresher, set by whichever mount is current.
|
|
348
|
+
*
|
|
349
|
+
* A mount that failed is retried, and the retry brings a new refresher bound
|
|
350
|
+
* to the Contribution that actually mounted — so the last writer wins rather
|
|
351
|
+
* than the first. Refusing the second one turned a recovered mount into a
|
|
352
|
+
* different, permanent failure.
|
|
353
|
+
*/
|
|
354
|
+
setAlarmRefresher(
|
|
355
|
+
refresh: (transaction: DurableObjectTransaction) => Promise<void>,
|
|
356
|
+
): void {
|
|
357
|
+
this.alarmRefresher = refresh;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async commitComputer(
|
|
361
|
+
transaction: DurableObjectTransaction,
|
|
362
|
+
): Promise<ConversationUpdateV1[]> {
|
|
363
|
+
return commitPublicationsV1(transaction, [
|
|
364
|
+
{
|
|
365
|
+
kind: "computer",
|
|
366
|
+
entityId: COMPUTER_ENTITY_ID_V1,
|
|
367
|
+
payload: {},
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Delivers one bounded batch of committed publication to attached
|
|
374
|
+
* observers, then advances `broadcastThrough`. Missing subscribers still
|
|
375
|
+
* complete the attempt so an idle object is not kept awake.
|
|
376
|
+
*/
|
|
377
|
+
async drainBroadcast(): Promise<boolean> {
|
|
378
|
+
if (this.silenced) return false;
|
|
379
|
+
return drainPendingPublicationV1(
|
|
380
|
+
this.state.storage,
|
|
381
|
+
(updates) => {
|
|
382
|
+
this.broadcastCommitted(updates);
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
refreshAlarm: (transaction) =>
|
|
386
|
+
this.refreshAlarm(transaction as unknown as DurableObjectTransaction),
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Broadcasts already-committed updates over hibernating observer sockets.
|
|
393
|
+
* Delivery is an attempt: one slow reader cannot hold the Turn, and a
|
|
394
|
+
* crash after send but before the marker may repeat frames.
|
|
395
|
+
*/
|
|
396
|
+
broadcastCommitted(updates: readonly ConversationUpdateV1[]): void {
|
|
397
|
+
if (this.silenced || updates.length === 0) return;
|
|
398
|
+
for (const update of updates) {
|
|
399
|
+
let frames: ProtocolFrames;
|
|
400
|
+
const frame = updateFrame(update);
|
|
401
|
+
try {
|
|
402
|
+
frames = new ProtocolFrames(frame);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.error("bot-state delivery failed", {
|
|
405
|
+
frame: frame.type,
|
|
406
|
+
bytes: frameBytes(frame),
|
|
407
|
+
error: error instanceof Error ? error.message : String(error),
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
this.sendFrames(frames, update.epoch, update.cursor);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Shows the reply a running Turn is writing to every observer that asked
|
|
417
|
+
* for drafts. Nothing is appended: a draft has no cursor, a reconnect does
|
|
418
|
+
* not replay it, and the message it previews is the committed record. One
|
|
419
|
+
* too large for a single frame is not sent, so the draft stops growing
|
|
420
|
+
* there and the message still arrives whole.
|
|
421
|
+
*/
|
|
422
|
+
broadcastDraft(draft: ReplyDraftV1): void {
|
|
423
|
+
if (this.silenced) return;
|
|
424
|
+
const encoded = encodeFrame({
|
|
425
|
+
schemaVersion: 1,
|
|
426
|
+
type: "state/draft",
|
|
427
|
+
runId: draft.runId,
|
|
428
|
+
ordinal: draft.ordinal,
|
|
429
|
+
parts: draft.parts,
|
|
430
|
+
});
|
|
431
|
+
if (utf8.encode(encoded).length > STATE_FRAME_MAX_BYTES) return;
|
|
432
|
+
for (const socket of this.state.getWebSockets(DRAFT_TAG)) {
|
|
433
|
+
try {
|
|
434
|
+
socket.send(encoded);
|
|
435
|
+
} catch {
|
|
436
|
+
// A socket that cannot take a preview fails its next committed frame
|
|
437
|
+
// too, and that path closes it.
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Append and broadcast one `computer` invalidation for a write the Bot made
|
|
444
|
+
* outside this Durable Object's storage.
|
|
445
|
+
*
|
|
446
|
+
* A screenshot the Bot files mid-Turn lands in the Workspace, not in DO
|
|
447
|
+
* storage, so no `ChannelComputerStorage` write announces it. Coalesced on
|
|
448
|
+
* the Computer interval: a Turn running Computer actions back to back only
|
|
449
|
+
* ever needs the browser to know it should read again.
|
|
450
|
+
*/
|
|
451
|
+
noticeComputer(): void {
|
|
452
|
+
if (this.silenced) return;
|
|
453
|
+
this.computerPending = true;
|
|
454
|
+
if (this.computerNotice) return;
|
|
455
|
+
this.computerNotice = (async () => {
|
|
456
|
+
while (this.computerPending) {
|
|
457
|
+
const wait =
|
|
458
|
+
this.runsNoticeIntervalMs - (Date.now() - this.computerNoticeAt);
|
|
459
|
+
if (wait > 0) await delay(wait);
|
|
460
|
+
this.computerPending = false;
|
|
461
|
+
this.computerNoticeAt = Date.now();
|
|
462
|
+
if (this.silenced) return;
|
|
463
|
+
await this.state.storage.transaction(async (transaction) => {
|
|
464
|
+
await this.commitComputer(transaction);
|
|
465
|
+
await this.refreshAlarm(transaction);
|
|
466
|
+
});
|
|
467
|
+
await this.drainBroadcast();
|
|
468
|
+
}
|
|
469
|
+
})()
|
|
470
|
+
.catch(() => {
|
|
471
|
+
// An observer notice is never authority. A dropped one costs the
|
|
472
|
+
// client its next poll, and the capture it described stands.
|
|
473
|
+
})
|
|
474
|
+
.finally(() => {
|
|
475
|
+
this.computerNotice = undefined;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Append and broadcast one `look` invalidation: what this Bot wears changed
|
|
481
|
+
* without the client asking, so an attached client reads it again.
|
|
482
|
+
*/
|
|
483
|
+
async noticeLook(): Promise<void> {
|
|
484
|
+
if (this.silenced) return;
|
|
485
|
+
await this.state.storage.transaction(async (transaction) => {
|
|
486
|
+
await commitPublicationsV1(transaction, [
|
|
487
|
+
{ kind: "look", entityId: LOOK_ENTITY_ID_V1, payload: {} },
|
|
488
|
+
]);
|
|
489
|
+
await this.refreshAlarm(transaction);
|
|
490
|
+
});
|
|
491
|
+
await this.drainBroadcast();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Stops this channel writing, for good. Called by the Bot's teardown before
|
|
496
|
+
* it wipes storage.
|
|
497
|
+
*/
|
|
498
|
+
silence(): void {
|
|
499
|
+
this.silenced = true;
|
|
500
|
+
this.computerPending = false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The configured callback belongs to the kernel; Packages contribute only deadlines. */
|
|
504
|
+
refreshAlarm(transaction: DurableObjectTransaction): Promise<void> {
|
|
505
|
+
return this.alarmRefresher?.(transaction) ?? Promise.resolve();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private sendFrames(
|
|
509
|
+
frames: ProtocolFrames,
|
|
510
|
+
epoch: number,
|
|
511
|
+
cursor: number,
|
|
512
|
+
): void {
|
|
513
|
+
for (const socket of this.state.getWebSockets(CHANNEL_TAG)) {
|
|
514
|
+
try {
|
|
515
|
+
const attachment = decodeAttachment(socket.deserializeAttachment());
|
|
516
|
+
if (!attachment || Number(attachment.epoch) !== epoch) continue;
|
|
517
|
+
if (Number(attachment.lastSent) >= cursor) continue;
|
|
518
|
+
for (const frame of frames.for(attachment)) socket.send(frame);
|
|
519
|
+
socket.serializeAttachment({
|
|
520
|
+
...attachment,
|
|
521
|
+
lastSent: String(cursor),
|
|
522
|
+
} satisfies ChannelAttachmentV1);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
console.error("bot-state delivery failed", {
|
|
525
|
+
frame: frames.type,
|
|
526
|
+
bytes: frames.bytes,
|
|
527
|
+
error: error instanceof Error ? error.message : String(error),
|
|
528
|
+
});
|
|
529
|
+
try {
|
|
530
|
+
socket.close(1011, "delivery failed");
|
|
531
|
+
} catch {
|
|
532
|
+
// A detached observer has no bearing on the durable operation.
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async upgrade(
|
|
539
|
+
request: Request,
|
|
540
|
+
identity: { userId: string; botId: string },
|
|
541
|
+
): Promise<Response> {
|
|
542
|
+
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
|
543
|
+
return Response.json(
|
|
544
|
+
{ error: "WebSocket upgrade required" },
|
|
545
|
+
{ status: 426 },
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
const url = new URL(request.url);
|
|
549
|
+
if (url.searchParams.get("version") !== "1") {
|
|
550
|
+
return Response.json(
|
|
551
|
+
{ error: "unsupported Bot-state protocol" },
|
|
552
|
+
{ status: 400 },
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
const drafts = url.searchParams.get("drafts") === "1";
|
|
556
|
+
const presentedCursor = url.searchParams.get("cursor");
|
|
557
|
+
const presentedEpoch = url.searchParams.get("epoch");
|
|
558
|
+
// A browser cannot put a header on a WebSocket, so the client names its
|
|
559
|
+
// protocol here. One that names none is the oldest this deployment serves.
|
|
560
|
+
const presentedProtocol = Number(url.searchParams.get("protocol") ?? "2");
|
|
561
|
+
const protocol =
|
|
562
|
+
Number.isSafeInteger(presentedProtocol) && presentedProtocol > 0
|
|
563
|
+
? presentedProtocol
|
|
564
|
+
: 2;
|
|
565
|
+
let cursor: number | undefined;
|
|
566
|
+
let epoch: number | undefined;
|
|
567
|
+
if (presentedCursor !== null) {
|
|
568
|
+
try {
|
|
569
|
+
cursor = Number(decodeBotStateCursorV1(presentedCursor));
|
|
570
|
+
} catch {
|
|
571
|
+
return Response.json(
|
|
572
|
+
{ error: "invalid Bot-state cursor" },
|
|
573
|
+
{ status: 400 },
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (presentedEpoch !== null) {
|
|
578
|
+
try {
|
|
579
|
+
epoch = Number(decodeBotStateCursorV1(presentedEpoch));
|
|
580
|
+
} catch {
|
|
581
|
+
return Response.json(
|
|
582
|
+
{ error: "invalid Bot-state epoch" },
|
|
583
|
+
{ status: 400 },
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Keep the snapshot and socket registration contiguous with respect to
|
|
589
|
+
// other object events. Otherwise a write could commit after the snapshot
|
|
590
|
+
// but before the socket is registered, silently skipping its update.
|
|
591
|
+
return this.state.blockConcurrencyWhile(async () => {
|
|
592
|
+
const handshake = await this.state.storage.transaction(
|
|
593
|
+
async (transaction) => {
|
|
594
|
+
const head = await readPublicationHeadV1(transaction);
|
|
595
|
+
const conversation = await readConversationSnapshotV1(transaction);
|
|
596
|
+
return this.handshakeFrames(
|
|
597
|
+
transaction,
|
|
598
|
+
head,
|
|
599
|
+
conversation,
|
|
600
|
+
cursor,
|
|
601
|
+
epoch,
|
|
602
|
+
);
|
|
603
|
+
},
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const pair = new WebSocketPair();
|
|
607
|
+
const [client, server] = Object.values(pair);
|
|
608
|
+
this.state.acceptWebSocket(
|
|
609
|
+
server,
|
|
610
|
+
drafts ? [CHANNEL_TAG, DRAFT_TAG] : [CHANNEL_TAG],
|
|
611
|
+
);
|
|
612
|
+
const attachment = {
|
|
613
|
+
schemaVersion: 1,
|
|
614
|
+
...identity,
|
|
615
|
+
epoch: handshake.epoch,
|
|
616
|
+
lastSent: handshake.lastSent,
|
|
617
|
+
protocol,
|
|
618
|
+
} satisfies ChannelAttachmentV1;
|
|
619
|
+
server.serializeAttachment(attachment);
|
|
620
|
+
for (const frame of handshake.frames) {
|
|
621
|
+
try {
|
|
622
|
+
for (const encoded of new ProtocolFrames(frame).for(attachment))
|
|
623
|
+
server.send(encoded);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
// Every client is refused the same way until the cause is gone,
|
|
626
|
+
// and the client only ever sees the close code.
|
|
627
|
+
console.error("bot-state handshake failed", {
|
|
628
|
+
botId: identity.botId,
|
|
629
|
+
frame: frame.type,
|
|
630
|
+
bytes: frameBytes(frame),
|
|
631
|
+
error: error instanceof Error ? error.message : String(error),
|
|
632
|
+
});
|
|
633
|
+
server.close(1011, "handshake failed");
|
|
634
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private async handshakeFrames(
|
|
642
|
+
storage: {
|
|
643
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
644
|
+
},
|
|
645
|
+
head: PublicationHeadV1,
|
|
646
|
+
conversation: Awaited<ReturnType<typeof readConversationSnapshotV1>>,
|
|
647
|
+
cursor: number | undefined,
|
|
648
|
+
epoch: number | undefined,
|
|
649
|
+
): Promise<{ epoch: string; lastSent: string; frames: CursoredFrameV1[] }> {
|
|
650
|
+
const snapshot = (
|
|
651
|
+
reason: "initial" | "gap" | "cursor-ahead" | "epoch",
|
|
652
|
+
): CursoredFrameV1 => ({
|
|
653
|
+
schemaVersion: 1,
|
|
654
|
+
type: "state/snapshot",
|
|
655
|
+
epoch: String(head.epoch),
|
|
656
|
+
cursor: String(head.lastCursor),
|
|
657
|
+
reason,
|
|
658
|
+
conversation: conversation as ConversationProjection,
|
|
659
|
+
});
|
|
660
|
+
const ready: CursoredFrameV1 = {
|
|
661
|
+
schemaVersion: 1,
|
|
662
|
+
type: "state/ready",
|
|
663
|
+
epoch: String(head.epoch),
|
|
664
|
+
cursor: String(head.lastCursor),
|
|
665
|
+
};
|
|
666
|
+
const withReady = (frames: CursoredFrameV1[]) => ({
|
|
667
|
+
epoch: String(head.epoch),
|
|
668
|
+
lastSent: String(head.lastCursor),
|
|
669
|
+
frames: [...frames, ready],
|
|
670
|
+
});
|
|
671
|
+
const reason = planHandshakeV1(head, cursor, epoch);
|
|
672
|
+
if (reason !== "replay") {
|
|
673
|
+
return withReady([snapshot(reason)]);
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
const updates = await readReplayUpdatesV1(storage, head, cursor!);
|
|
677
|
+
return withReady(updates.map(updateFrame));
|
|
678
|
+
} catch {
|
|
679
|
+
return withReady([snapshot("gap")]);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
message(socket: WebSocket): void {
|
|
684
|
+
socket.close(1003, "server-push channel");
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
close(socket: WebSocket, code: number, reason: string): void {
|
|
688
|
+
try {
|
|
689
|
+
socket.close(code, reason);
|
|
690
|
+
} catch {
|
|
691
|
+
// Hibernation cleanup only; observer state is never authoritative.
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
error(socket: WebSocket): void {
|
|
696
|
+
try {
|
|
697
|
+
socket.close(1011, "socket error");
|
|
698
|
+
} catch {
|
|
699
|
+
// Hibernation cleanup only; observer state is never authoritative.
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|