@threahq/bot-runtime-client 0.1.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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/archive-grace.d.ts +112 -0
- package/attachment-files.d.ts +15 -0
- package/crypto.d.ts +206 -0
- package/index.d.ts +14 -0
- package/index.js +2344 -0
- package/index.js.map +19 -0
- package/invocation-control.d.ts +128 -0
- package/keyring.d.ts +242 -0
- package/package.json +49 -0
- package/sealed-stream-client.d.ts +145 -0
- package/sealed.d.ts +305 -0
- package/transport-test-helpers.d.ts +53 -0
- package/transport.d.ts +132 -0
- package/types.d.ts +243 -0
- package/user-key.d.ts +53 -0
- package/ws-hint.d.ts +17 -0
package/sealed.d.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealed (E2EE) turn support for bot-runtime harnesses.
|
|
3
|
+
*
|
|
4
|
+
* A harness that serves an end-to-end-encrypted scratchpad holds a keyring of
|
|
5
|
+
* BIKs (Bot Identity Keys): X25519 keypairs the owner wraps the stream's
|
|
6
|
+
* symmetric key (SSK) to. One key per host is the default, so every runtime on
|
|
7
|
+
* a box shares it; a key can also be pinned to a single stream. On a winning
|
|
8
|
+
* claim the backend hands the harness a
|
|
9
|
+
* `sealedContext` — SSK wraps addressed to one of its keys plus the sealed
|
|
10
|
+
* trigger and history ciphertext — and the harness seals every reply and trace step back
|
|
11
|
+
* under the same SSK. The server never sees plaintext (INV-E7); the owner's
|
|
12
|
+
* client opens the harness's output exactly as it opens the enclave's.
|
|
13
|
+
*
|
|
14
|
+
* This module is pure crypto + a small keystore; it does no HTTP. Transport
|
|
15
|
+
* routing lives in `BotRuntimeTransport` (sealed steps) and each harness's own
|
|
16
|
+
* HTTP client (sealed complete / interim messages, low-frequency writes).
|
|
17
|
+
*/
|
|
18
|
+
import type { E2eKeyRecord, E2eKeyring } from "./keyring.js";
|
|
19
|
+
import { type WebCryptoKey, type AttachmentRef, type SealedPayloadExtras, type StreamEnvelope } from "./crypto.js";
|
|
20
|
+
/**
|
|
21
|
+
* Per-claim secret (model A) the backend hands a sealed turn in `sealedContext`;
|
|
22
|
+
* echoed on every sealed callback so the backend can bind it to that session.
|
|
23
|
+
*/
|
|
24
|
+
export declare const THREA_CALLBACK_TOKEN_HEADER = "X-Threa-Callback-Token";
|
|
25
|
+
/** This install's registered Bot Identity Key — held in memory; the private key never re-exports once loaded. */
|
|
26
|
+
export interface BotIdentityKey {
|
|
27
|
+
publicKeyId: string;
|
|
28
|
+
publicKeyBase64: string;
|
|
29
|
+
privateKey: WebCryptoKey;
|
|
30
|
+
}
|
|
31
|
+
/** One SSK wrap addressed to this bot's BIK (wire shape from the claim's `sealedContext`). */
|
|
32
|
+
export interface SealedSskWrap {
|
|
33
|
+
keyGeneration: number;
|
|
34
|
+
wrapEnc: string;
|
|
35
|
+
wrapCt: string;
|
|
36
|
+
}
|
|
37
|
+
/** One SSK-sealed message: base64 ciphertext + its envelope (wire shape). */
|
|
38
|
+
export interface SealedMessageWire {
|
|
39
|
+
ciphertext: string;
|
|
40
|
+
envelope: StreamEnvelope;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The sealed work handed to an owner-granted external bot on a winning claim
|
|
44
|
+
* when the delivery verdict is `sealed`. Mirrors `@threahq/types`' `SealedTurnContext`
|
|
45
|
+
* (which standalone extensions can't import). The backend never decrypts: it
|
|
46
|
+
* ships ciphertext + SSK wraps addressed to this bot's BIK; the bot unwraps
|
|
47
|
+
* with its identity private key, opens history/prompt, runs its turn, and seals
|
|
48
|
+
* each reply/step back under the same SSK.
|
|
49
|
+
*/
|
|
50
|
+
export interface SealedTurnContext {
|
|
51
|
+
callbackToken: string;
|
|
52
|
+
wraps: SealedSskWrap[];
|
|
53
|
+
history: (SealedMessageWire & {
|
|
54
|
+
role: "user" | "assistant";
|
|
55
|
+
sequence: string;
|
|
56
|
+
})[];
|
|
57
|
+
prompt: SealedMessageWire;
|
|
58
|
+
reply: {
|
|
59
|
+
keyGeneration: number;
|
|
60
|
+
senderId: string;
|
|
61
|
+
};
|
|
62
|
+
trigger?: {
|
|
63
|
+
messageId: string;
|
|
64
|
+
authorName: string;
|
|
65
|
+
authorType: string;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Everything a sealed turn needs to seal its replies/steps back under the stream key. */
|
|
70
|
+
export interface SealingState {
|
|
71
|
+
/** E2E root stream id — bound into every wrap/message/step AAD. */
|
|
72
|
+
streamId: string;
|
|
73
|
+
replyKeyGeneration: number;
|
|
74
|
+
replySenderId: string;
|
|
75
|
+
/** The recovered SSK for `replyKeyGeneration`; replies and steps seal under it. */
|
|
76
|
+
replySsk: Uint8Array;
|
|
77
|
+
callbackToken: string;
|
|
78
|
+
}
|
|
79
|
+
/** One decrypted prior message, oldest→newest. Formatting into a prompt is the harness's job. */
|
|
80
|
+
export interface DecryptedHistoryItem {
|
|
81
|
+
role: "user" | "assistant";
|
|
82
|
+
sequence: string;
|
|
83
|
+
contentMarkdown: string;
|
|
84
|
+
/** Per-file keys for the message's E2E attachments — download + decrypt is the harness's job. */
|
|
85
|
+
attachmentRefs: AttachmentRef[];
|
|
86
|
+
}
|
|
87
|
+
export interface OpenedSealedTurn {
|
|
88
|
+
promptMarkdown: string;
|
|
89
|
+
/** Refs sealed into the trigger message's payload (the files attached to the request itself). */
|
|
90
|
+
promptAttachmentRefs: AttachmentRef[];
|
|
91
|
+
history: DecryptedHistoryItem[];
|
|
92
|
+
sealing: SealingState;
|
|
93
|
+
}
|
|
94
|
+
/** The body of a sealed reply or interim message: `msg_…` id in clear, content sealed. */
|
|
95
|
+
export interface SealedReplyBody {
|
|
96
|
+
messageId: string;
|
|
97
|
+
ciphertext: string;
|
|
98
|
+
envelope: StreamEnvelope;
|
|
99
|
+
}
|
|
100
|
+
/** One sealed trace step (the `/sealed-steps` wire shape; `stepId` keys the row, content is ciphertext). */
|
|
101
|
+
export interface SealedStepFrame {
|
|
102
|
+
stepId: string;
|
|
103
|
+
stepType: string;
|
|
104
|
+
messageId?: string;
|
|
105
|
+
ciphertext: string;
|
|
106
|
+
envelope: StreamEnvelope;
|
|
107
|
+
durationMs?: number;
|
|
108
|
+
}
|
|
109
|
+
/** Mint a fresh identity key record: a `bik_…` id and an X25519 keypair, base64. */
|
|
110
|
+
export declare function mintE2eKeyRecord(): Promise<E2eKeyRecord>;
|
|
111
|
+
/**
|
|
112
|
+
* This install's identity keys, ready to open sealed turns. The records live in
|
|
113
|
+
* an {@link E2eKeyring} (keychain or file); this adds the WebCrypto import and
|
|
114
|
+
* caches the result for the process.
|
|
115
|
+
*
|
|
116
|
+
* The public halves must ride EVERY `bot:hello` and presence update: the
|
|
117
|
+
* server reads an advertised keyring as the instance's complete set, so a
|
|
118
|
+
* heartbeat that omits it unregisters every key and breaks sealed-claim wrap
|
|
119
|
+
* coverage.
|
|
120
|
+
*/
|
|
121
|
+
export declare class BotKeyring {
|
|
122
|
+
private readonly buildRecords;
|
|
123
|
+
private readonly log;
|
|
124
|
+
private records;
|
|
125
|
+
private cached;
|
|
126
|
+
private queue;
|
|
127
|
+
private loaded;
|
|
128
|
+
constructor(opts: {
|
|
129
|
+
keyring: () => E2eKeyring;
|
|
130
|
+
log?: (message: string) => void;
|
|
131
|
+
});
|
|
132
|
+
/** The loaded keys, if `ensure()` has resolved. */
|
|
133
|
+
get identities(): BotIdentityKey[];
|
|
134
|
+
/**
|
|
135
|
+
* Load or create this install's keys, caching them for the process. Returns
|
|
136
|
+
* an empty keyring when the store or WebCrypto fails, logged loudly: the
|
|
137
|
+
* harness then serves plaintext streams only, rather than sealed turns
|
|
138
|
+
* becoming unservable with no clue why. Nothing is downgraded by that — a
|
|
139
|
+
* runtime with no registered key cannot claim a sealed stream at all.
|
|
140
|
+
*/
|
|
141
|
+
ensure(): Promise<BotIdentityKey[]>;
|
|
142
|
+
/**
|
|
143
|
+
* The key this install reads `streamId` with, imported alongside the rest.
|
|
144
|
+
* Under the default policy one key already covers every stream and this is
|
|
145
|
+
* the plain `ensure()`; under the per-stream policy it mints this stream's
|
|
146
|
+
* key, so presence can advertise it before the owner re-wraps.
|
|
147
|
+
*/
|
|
148
|
+
ensureForStream(streamId: string): Promise<BotIdentityKey[]>;
|
|
149
|
+
/**
|
|
150
|
+
* The identity a wrap for `streamId` must be addressed to, minting it first
|
|
151
|
+
* if this install does not hold it yet. Undefined when no key could be
|
|
152
|
+
* created at all, which is the one case a caller must treat as "sealed is
|
|
153
|
+
* unavailable here" rather than falling back to another key.
|
|
154
|
+
*/
|
|
155
|
+
identityForStream(streamId: string): Promise<BotIdentityKey | undefined>;
|
|
156
|
+
/**
|
|
157
|
+
* Forget the key held for a stream this bot was just revoked from, so the
|
|
158
|
+
* next presence write stops advertising it. A no-op under every policy but
|
|
159
|
+
* per-stream, where the key exists for that one scratchpad and nothing else.
|
|
160
|
+
*/
|
|
161
|
+
dropStream(streamId: string): Promise<BotIdentityKey[]>;
|
|
162
|
+
/** The fields to spread into every `bot:hello` and presence body. Empty until `ensure()` resolves. */
|
|
163
|
+
presenceFields(): ReturnType<E2eKeyring["presenceFields"]>;
|
|
164
|
+
/**
|
|
165
|
+
* One key operation at a time. Boot presence, `bot:hello` and a grant can all
|
|
166
|
+
* land together, and each rebuilds the imported set from the keyring's whole
|
|
167
|
+
* record list — interleaved, the slower one would publish a set missing the
|
|
168
|
+
* key the other had just added.
|
|
169
|
+
*/
|
|
170
|
+
private enqueue;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Validate a claim response's `sealedContext` field. The claim body is untyped
|
|
174
|
+
* JSON at the harness boundary; a malformed context returns `undefined` so the
|
|
175
|
+
* caller can fail the invocation loudly instead of crashing mid-hydration.
|
|
176
|
+
*/
|
|
177
|
+
export declare function parseSealedTurnContext(raw: unknown): SealedTurnContext | undefined;
|
|
178
|
+
/**
|
|
179
|
+
* Open a sealed claim with this bot's keyring: recover the SSK for every generation
|
|
180
|
+
* the backend wrapped to us (AAD-bound to our key id), open the trigger + prior
|
|
181
|
+
* history, and return the decrypted prompt plus the {@link SealingState} the turn
|
|
182
|
+
* seals replies/steps with. `streamId` is the E2E root stream — wraps and the
|
|
183
|
+
* owner's message AAD both bind to it. A wrap or history row we can't open is
|
|
184
|
+
* skipped (a generation predating our invite), never fatal; a missing reply or
|
|
185
|
+
* prompt key is fatal (the turn can't be served).
|
|
186
|
+
*/
|
|
187
|
+
export declare function openSealedTurnContext(params: {
|
|
188
|
+
sealed: SealedTurnContext;
|
|
189
|
+
identities: BotIdentityKey[];
|
|
190
|
+
streamId: string;
|
|
191
|
+
}): Promise<OpenedSealedTurn>;
|
|
192
|
+
/**
|
|
193
|
+
* Seal a reply (or interim message) under the stream key, bound to a fresh
|
|
194
|
+
* `msg_…` id — the body of the sealed `/complete` reply and of a sealed
|
|
195
|
+
* interim `/sealed-messages` post.
|
|
196
|
+
*/
|
|
197
|
+
export declare function sealReply(sealing: SealingState, markdown: string, extras?: SealedPayloadExtras): Promise<SealedReplyBody>;
|
|
198
|
+
/**
|
|
199
|
+
* Seal one trace step under the stream key, bound to a fresh `step_…` id — the
|
|
200
|
+
* sealed `/steps` wire shape. The `step_…` id rides the `messageId` slot of the
|
|
201
|
+
* message AAD, exactly as the enclave's trace-observer binds its steps.
|
|
202
|
+
* Content is sealed as-given: clamping oversized tool output is the caller's
|
|
203
|
+
* policy, not hidden truncation here.
|
|
204
|
+
*/
|
|
205
|
+
export declare function sealStep(sealing: SealingState, stepType: string, content: string, opts?: {
|
|
206
|
+
durationMs?: number;
|
|
207
|
+
}): Promise<SealedStepFrame>;
|
|
208
|
+
/** The words a sealed decision card carries; the wire row holds placeholders in their place. */
|
|
209
|
+
export interface SealedDecisionContent {
|
|
210
|
+
title: string;
|
|
211
|
+
bodyMarkdown?: string;
|
|
212
|
+
/** Option id → the label to show on its button. Ids and tones stay in the clear. */
|
|
213
|
+
optionLabels: Record<string, string>;
|
|
214
|
+
}
|
|
215
|
+
/** The sealed half of a decision card, with the id its AAD is bound to. */
|
|
216
|
+
export interface SealedDecisionCard {
|
|
217
|
+
decisionId: string;
|
|
218
|
+
ciphertext: string;
|
|
219
|
+
envelope: StreamEnvelope;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Seal a decision card's question under the stream key, bound to a fresh
|
|
223
|
+
* `dreq_…` id — the `decisionId` + `sealed` half of a sealed create.
|
|
224
|
+
*
|
|
225
|
+
* The AAD names the stream the CARD lives on, which on a thread is not
|
|
226
|
+
* `sealing.streamId` (that is the root the key hangs off, and what every wrap
|
|
227
|
+
* and message AAD binds to). Pass the stream being posted to; the server checks
|
|
228
|
+
* the same string and refuses a card sealed to another slot.
|
|
229
|
+
*/
|
|
230
|
+
export declare function sealDecision(sealing: SealingState, card: {
|
|
231
|
+
streamId: string;
|
|
232
|
+
requesterBotId: string;
|
|
233
|
+
}, content: SealedDecisionContent): Promise<SealedDecisionCard>;
|
|
234
|
+
/**
|
|
235
|
+
* Open the sealed note a member attached to their answer. Returns null when the
|
|
236
|
+
* envelope names another slot, or a key generation this turn does not hold — a
|
|
237
|
+
* rotation between opening the card and answering it leaves the answer readable
|
|
238
|
+
* and its note not, and losing the note beats losing the answer.
|
|
239
|
+
*/
|
|
240
|
+
export declare function openSealedDecisionNote(sealing: SealingState, note: {
|
|
241
|
+
streamId: string;
|
|
242
|
+
decisionId: string;
|
|
243
|
+
decidedBy: string;
|
|
244
|
+
ciphertext: string;
|
|
245
|
+
envelope: StreamEnvelope;
|
|
246
|
+
}): Promise<string | null>;
|
|
247
|
+
/** Error text for a sealed `/fail`: class name only, never the message — it could echo decrypted content. */
|
|
248
|
+
export declare function scrubSealedError(error: unknown): string;
|
|
249
|
+
/**
|
|
250
|
+
* The minimal sealed material a claim carries for a session-control command
|
|
251
|
+
* (e.g. `/model`) on an E2E scratchpad: the current-generation SSK wraps
|
|
252
|
+
* addressed to this bot's BIK plus the reply binding. No trigger/history — the
|
|
253
|
+
* command name is cleartext dispatch metadata, so only the ack needs sealing.
|
|
254
|
+
* Absent when the bot can't seal (no key / wrap race); the harness then closes
|
|
255
|
+
* the command silently.
|
|
256
|
+
*/
|
|
257
|
+
export interface SealedAckContext {
|
|
258
|
+
wraps: SealedSskWrap[];
|
|
259
|
+
reply: {
|
|
260
|
+
keyGeneration: number;
|
|
261
|
+
senderId: string;
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** Validate a claim's `sealedAck` field (untyped JSON at the harness boundary). */
|
|
265
|
+
export declare function parseSealedAckContext(raw: unknown): SealedAckContext | undefined;
|
|
266
|
+
/**
|
|
267
|
+
* Open a session-control sealed ack: unwrap the SSK for the reply generation
|
|
268
|
+
* with this bot's BIK and return the {@link SealingState} `sealReply` seals the
|
|
269
|
+
* ack with. `callbackToken` is empty — a session-control ack authorizes with the
|
|
270
|
+
* claim token on `/complete`, not a per-turn callback token. Throws when no wrap
|
|
271
|
+
* covers the reply generation (a key race); the caller falls back to a silent close.
|
|
272
|
+
*/
|
|
273
|
+
export declare function openSealedAck(params: {
|
|
274
|
+
ack: SealedAckContext;
|
|
275
|
+
identities: BotIdentityKey[];
|
|
276
|
+
streamId: string;
|
|
277
|
+
}): Promise<SealingState>;
|
|
278
|
+
/** One recipient a freshly-minted stream key is wrapped to. */
|
|
279
|
+
export interface ProvisionRecipient {
|
|
280
|
+
recipientKind: "user" | "bot";
|
|
281
|
+
/** UIK/BIK key id — the AAD binds the wrap to this slot. */
|
|
282
|
+
recipientKeyId: string;
|
|
283
|
+
/** Base64 raw X25519 public key. */
|
|
284
|
+
publicKeyBase64: string;
|
|
285
|
+
}
|
|
286
|
+
export interface ProvisionedWrap {
|
|
287
|
+
recipientKind: "user" | "bot";
|
|
288
|
+
recipientKeyId: string;
|
|
289
|
+
wrapEnc: string;
|
|
290
|
+
wrapCt: string;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Mint a fresh generation-0 stream key for a harness-created E2E scratchpad and
|
|
294
|
+
* wrap it to each recipient (the owner's UIK + this install's BIK) — the wire
|
|
295
|
+
* body of the phase-two provisioning POST. The SSK itself is returned only so
|
|
296
|
+
* the caller can drop it deliberately: future turns recover it from the claim's
|
|
297
|
+
* wraps, so nothing needs (or should) persist it locally.
|
|
298
|
+
*/
|
|
299
|
+
export declare function mintStreamKeyWraps(params: {
|
|
300
|
+
streamId: string;
|
|
301
|
+
keyGeneration: number;
|
|
302
|
+
recipients: ProvisionRecipient[];
|
|
303
|
+
}): Promise<{
|
|
304
|
+
wraps: ProvisionedWrap[];
|
|
305
|
+
}>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mock } from "bun:test";
|
|
2
|
+
import type { InvocationControlScheduler } from "./invocation-control.js";
|
|
3
|
+
import type { BotRuntimeTransport } from "./transport.js";
|
|
4
|
+
import type { BotRuntimeHello, BotRuntimeTransportOptions } from "./types.js";
|
|
5
|
+
export declare const TEST_TRANSPORT_REQUEST_CONFIG: {
|
|
6
|
+
readonly baseUrl: "https://app.example.test";
|
|
7
|
+
readonly workspaceId: "ws_1";
|
|
8
|
+
readonly apiKey: "threa_bk_test";
|
|
9
|
+
};
|
|
10
|
+
export declare function testTransportOptions(hello: BotRuntimeHello, overrides?: Partial<BotRuntimeTransportOptions>): BotRuntimeTransportOptions;
|
|
11
|
+
export declare function waitFor(predicate: () => boolean, timeoutMs?: number): Promise<void>;
|
|
12
|
+
export declare function deferred<T>(): {
|
|
13
|
+
promise: Promise<T>;
|
|
14
|
+
resolve: (value: T) => void;
|
|
15
|
+
};
|
|
16
|
+
export declare class FakeScheduler implements InvocationControlScheduler {
|
|
17
|
+
private nowMs;
|
|
18
|
+
private nextId;
|
|
19
|
+
private readonly tasks;
|
|
20
|
+
private fired;
|
|
21
|
+
get pendingCount(): number;
|
|
22
|
+
get firedCount(): number;
|
|
23
|
+
get pendingDelays(): number[];
|
|
24
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
25
|
+
clearTimeout(handle: unknown): void;
|
|
26
|
+
advanceBy(ms: number): void;
|
|
27
|
+
}
|
|
28
|
+
export interface CapturedRequest {
|
|
29
|
+
url: string;
|
|
30
|
+
method: string;
|
|
31
|
+
body?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
export declare function json(data: unknown, status?: number): Response;
|
|
34
|
+
export declare function stubFetch(responder: (request: CapturedRequest) => Response | Promise<Response>): CapturedRequest[];
|
|
35
|
+
/** Restores `global.fetch` after each test; push spies onto the returned array to have them restored too. */
|
|
36
|
+
export declare function restoreFetchAfterEach(): {
|
|
37
|
+
mockRestore(): void;
|
|
38
|
+
}[];
|
|
39
|
+
export interface FakeSocket {
|
|
40
|
+
handlers: Record<string, (...args: unknown[]) => void>;
|
|
41
|
+
timeout: () => {
|
|
42
|
+
emit: (...args: unknown[]) => void;
|
|
43
|
+
};
|
|
44
|
+
emit: (...args: unknown[]) => void;
|
|
45
|
+
on: (event: string, callback: (...args: unknown[]) => void) => FakeSocket;
|
|
46
|
+
connect: ReturnType<typeof mock>;
|
|
47
|
+
disconnect: ReturnType<typeof mock>;
|
|
48
|
+
removeAllListeners: ReturnType<typeof mock>;
|
|
49
|
+
}
|
|
50
|
+
type FakeSocketResponder = (event: string, payload: unknown, callback: (error: unknown, ack?: unknown) => void) => void;
|
|
51
|
+
export declare function fakeSocket(responder?: FakeSocketResponder): FakeSocket;
|
|
52
|
+
export declare function attachReadySocket(transport: BotRuntimeTransport, socket: unknown): void;
|
|
53
|
+
export {};
|
package/transport.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { type SealedStepFrame } from "./sealed.js";
|
|
2
|
+
import { type ObserveClaimParams, type ObservedClaimHandle } from "./invocation-control.js";
|
|
3
|
+
import { type WsHint } from "./ws-hint.js";
|
|
4
|
+
import type { BotRuntimeTransportOptions, StepFrame } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Owns the `/bot` WebSocket and the routing for a runtime's background writes.
|
|
7
|
+
*
|
|
8
|
+
* Why it exists: every `presence` / `renew` / `steps` POST goes through the edge
|
|
9
|
+
* Worker (`app.threa.io/api/*`) and is billed as a request; the same frame over
|
|
10
|
+
* the already-open socket (a direct CNAME to the regional backend) is free. A
|
|
11
|
+
* single agentic turn can fire 150+ step writes, so moving them off HTTP is the
|
|
12
|
+
* difference between an idle daemon costing nothing and one steadily burning the
|
|
13
|
+
* Cloudflare quota.
|
|
14
|
+
*
|
|
15
|
+
* Routing rule for the three write methods: prefer the socket; if the server
|
|
16
|
+
* acks (ok or a definitive failure) trust it; only a missing ack or a dead
|
|
17
|
+
* socket triggers the HTTP fallback. Steps are best-effort; presence is
|
|
18
|
+
* low-stakes.
|
|
19
|
+
*
|
|
20
|
+
* The transport owns ONLY these three ops + the socket. Durable, low-frequency
|
|
21
|
+
* writes (claim/complete/fail/session) stay on each extension's own HTTP client.
|
|
22
|
+
*/
|
|
23
|
+
export declare class BotRuntimeTransport {
|
|
24
|
+
private readonly base;
|
|
25
|
+
private readonly workspaceId;
|
|
26
|
+
private readonly apiKey;
|
|
27
|
+
private readonly hello;
|
|
28
|
+
private readonly beforeHello;
|
|
29
|
+
private readonly callbacks;
|
|
30
|
+
private readonly wsAckTimeoutMs;
|
|
31
|
+
private readonly reconnectionDelayMaxMs;
|
|
32
|
+
private readonly fetchTimeoutMs;
|
|
33
|
+
private readonly staleSocketRedialMs;
|
|
34
|
+
private readonly logFn;
|
|
35
|
+
private socket;
|
|
36
|
+
private connected;
|
|
37
|
+
private helloReady;
|
|
38
|
+
private helloInFlight;
|
|
39
|
+
private connecting;
|
|
40
|
+
private stopped;
|
|
41
|
+
private redialTimer;
|
|
42
|
+
/** When the current outage started: set at attach and on disconnect, cleared on connect. */
|
|
43
|
+
private disconnectedAt;
|
|
44
|
+
/** The cursor echoed by the last hello ack; re-sent on the next hello so the bootstrap only replays unseen events. */
|
|
45
|
+
private cursor;
|
|
46
|
+
private readonly controls;
|
|
47
|
+
constructor(opts: BotRuntimeTransportOptions);
|
|
48
|
+
/** Whether the `/bot` socket is currently connected. */
|
|
49
|
+
get socketConnected(): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the WS hint and open the socket (idempotent — a guard keeps the boot
|
|
52
|
+
* call and the first poll tick from opening two). The hint resolve is the only
|
|
53
|
+
* HTTP the transport does on the hot path; a failure leaves the socket closed
|
|
54
|
+
* and the caller keeps polling/HTTP-writing until the next `connect()`.
|
|
55
|
+
*
|
|
56
|
+
* An existing-but-disconnected socket is normally left to Socket.IO's own
|
|
57
|
+
* retry loop, EXCEPT when the outage has outlived `staleSocketRedialMs`: then
|
|
58
|
+
* the socket is wedged in a state retries can't fix (a stale ws hint after the
|
|
59
|
+
* backend moved, a dead retry loop) and the only cure is a teardown + fresh
|
|
60
|
+
* dial. Without this, one wedged socket leaves the runtime on the fast HTTP
|
|
61
|
+
* poll forever — the exact Cloudflare-quota burn the transport exists to avoid.
|
|
62
|
+
*/
|
|
63
|
+
connect(): Promise<void>;
|
|
64
|
+
private attachSocket;
|
|
65
|
+
/** (Re)announce this instance + capabilities and pull the bootstrap snapshot. */
|
|
66
|
+
sendHello(): void;
|
|
67
|
+
/** Tear the socket down (idempotent). After this the transport is HTTP-only and won't reconnect. */
|
|
68
|
+
disconnect(): void;
|
|
69
|
+
private scheduleRedial;
|
|
70
|
+
/** Drop the current socket without stopping the transport — the next `connect()` dials fresh. */
|
|
71
|
+
private teardownSocket;
|
|
72
|
+
/** Own renewal and control synchronization for a claimed invocation. */
|
|
73
|
+
observeClaim(params: ObserveClaimParams): ObservedClaimHandle;
|
|
74
|
+
/**
|
|
75
|
+
* Record one or more trace steps. Best-effort: a turn's narration is nice to
|
|
76
|
+
* have, not load-bearing, so a failure is logged and dropped rather than
|
|
77
|
+
* surfaced. This is the high-volume op the whole exercise targets.
|
|
78
|
+
*/
|
|
79
|
+
recordSteps(invocationId: string, claimToken: string, steps: StepFrame[], statusText?: string, instanceId?: string): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Record one or more SEALED trace steps for an E2E turn. Same routing and
|
|
82
|
+
* best-effort semantics as {@link recordSteps} — WS frame first, per-step HTTP
|
|
83
|
+
* fallback — but the frames carry ciphertext + envelope instead of plaintext
|
|
84
|
+
* content, and auth is the per-claim callback token (model A), not
|
|
85
|
+
* `instanceId`/`claimToken`. `stepId` is the idempotency key: the server
|
|
86
|
+
* finalizes/upserts by it, so a duplicate delivery can't double-persist.
|
|
87
|
+
*/
|
|
88
|
+
recordSealedSteps(invocationId: string, callbackToken: string, steps: SealedStepFrame[]): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Renew a claim's lease. Correctness-critical, so the HTTP fallback is
|
|
91
|
+
* mandatory: a missing ack, a dead socket, or any non-`NOT_FOUND` server error
|
|
92
|
+
* all retry over HTTP. Returns `{ notFound: true }` when the claim is gone
|
|
93
|
+
* (the caller should drop it); the caller never lets it silently lapse.
|
|
94
|
+
* `renewed` is true only when the server confirmed the extension — a caller
|
|
95
|
+
* with side effects can stop work once a lease has gone unconfirmed for a
|
|
96
|
+
* full TTL rather than run on after another runtime may have claimed it.
|
|
97
|
+
*/
|
|
98
|
+
renewClaim(invocationId: string, claimToken: string, claimTtlSeconds: number, instanceId?: string): Promise<{
|
|
99
|
+
notFound: boolean;
|
|
100
|
+
renewed: boolean;
|
|
101
|
+
}>;
|
|
102
|
+
/**
|
|
103
|
+
* Push a presence update. Low-stakes (the socket connection itself is the
|
|
104
|
+
* primary liveness signal); if the socket can't ack it, fall back to HTTP so
|
|
105
|
+
* the row still lands. `body` is the full presence body, identical to the HTTP
|
|
106
|
+
* `/bot-runtime/presence` payload.
|
|
107
|
+
*/
|
|
108
|
+
updatePresence(body: Record<string, unknown>): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Emit a write event and await its ack.
|
|
111
|
+
*
|
|
112
|
+
* `sent` distinguishes the two failure modes that look identical at the ack
|
|
113
|
+
* layer but must NOT be handled the same way: `sent: false` means the frame
|
|
114
|
+
* never left (no live socket / `emit` threw), so an HTTP retry is the only way
|
|
115
|
+
* the write lands and is safe; `sent: true, ack: null` means the frame IS in
|
|
116
|
+
* flight but the server didn't ack within the timeout. Steps carry a
|
|
117
|
+
* `client_step_id` so a re-POST would dedup rather than duplicate, but it would
|
|
118
|
+
* still bill an edge request the WS path exists to avoid — so a best-effort
|
|
119
|
+
* caller drops on `sent` instead of retrying. Idempotent writes (renew CAS,
|
|
120
|
+
* presence upsert) ignore the distinction and retry on either.
|
|
121
|
+
*/
|
|
122
|
+
private emitWrite;
|
|
123
|
+
/** The wsUrl hint is served by the edge workspace-router at `/api/workspaces/:id/config` (NOT /api/v1). */
|
|
124
|
+
resolveWsHint(): Promise<WsHint | undefined>;
|
|
125
|
+
private httpRecordStepsFallback;
|
|
126
|
+
private httpRecordSealedStepsFallback;
|
|
127
|
+
private httpRenewFallback;
|
|
128
|
+
private syncObservedClaim;
|
|
129
|
+
private httpPresenceFallback;
|
|
130
|
+
private v1Path;
|
|
131
|
+
private httpRequest;
|
|
132
|
+
}
|