@bivy/bivy 0.18.5-staging.5 → 0.18.5-staging.7
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/dist/github-repos.js +57 -0
- package/dist/relay-attach.js +17 -5
- package/dist/relay-chunk.js +71 -38
- package/dist/remote/event-transfer-receiver.js +142 -0
- package/dist/remote/event-transfer.js +81 -0
- package/dist/remote/relay-client.js +16 -3
- package/dist/server.js +25 -36
- package/dist/session/client-projection.js +71 -0
- package/dist/session/sibling-client.js +12 -2
- package/dist/session/transcript-persistence.js +5 -2
- package/dist/wire-format.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** User credentials (configured token or gh login) can reach repos outside the Apps. */
|
|
2
|
+
export async function listGithubUserRepos(token, fetchImpl = fetch) {
|
|
3
|
+
const repos = [];
|
|
4
|
+
for (let page = 1;; page++) {
|
|
5
|
+
const response = await fetchImpl(`https://api.github.com/user/repos?sort=updated&per_page=100&affiliation=owner,collaborator,organization_member&page=${page}`, {
|
|
6
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": "bivy" },
|
|
7
|
+
});
|
|
8
|
+
if (!response.ok)
|
|
9
|
+
throw new Error(`GitHub responded ${response.status} while listing user repositories`);
|
|
10
|
+
const batch = await response.json();
|
|
11
|
+
if (!Array.isArray(batch))
|
|
12
|
+
throw new Error("GitHub returned an invalid repository listing");
|
|
13
|
+
for (const repo of batch) {
|
|
14
|
+
if (typeof repo?.full_name !== "string" || !repo.full_name)
|
|
15
|
+
continue;
|
|
16
|
+
repos.push({
|
|
17
|
+
slug: repo.full_name,
|
|
18
|
+
description: typeof repo.description === "string" ? repo.description : "",
|
|
19
|
+
private: Boolean(repo.private),
|
|
20
|
+
pushedAt: typeof repo.pushed_at === "string" ? repo.pushed_at : undefined,
|
|
21
|
+
defaultBranch: typeof repo.default_branch === "string" ? repo.default_branch : undefined,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (batch.length < 100)
|
|
25
|
+
return repos;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Query both sources independently; a failed credential must not hide usable repos. */
|
|
29
|
+
export async function discoverGithubRepos(sources) {
|
|
30
|
+
const results = await Promise.allSettled([sources.appRepos(), sources.userRepos()]);
|
|
31
|
+
const repos = new Map();
|
|
32
|
+
const errors = [];
|
|
33
|
+
let authed = false;
|
|
34
|
+
for (const [index, result] of results.entries()) {
|
|
35
|
+
if (result.status === "rejected") {
|
|
36
|
+
errors.push(`${index === 0 ? "GitHub App" : "GitHub user"}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (result.value === undefined)
|
|
40
|
+
continue;
|
|
41
|
+
authed = true;
|
|
42
|
+
for (const repo of result.value) {
|
|
43
|
+
const key = repo.slug.toLowerCase();
|
|
44
|
+
if (!repos.has(key))
|
|
45
|
+
repos.set(key, repo);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const listing = {
|
|
49
|
+
authed,
|
|
50
|
+
repos: [...repos.values()].sort((a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? "") || a.slug.localeCompare(b.slug)),
|
|
51
|
+
};
|
|
52
|
+
if (errors.length)
|
|
53
|
+
listing.error = errors.join("; ");
|
|
54
|
+
else if (!authed)
|
|
55
|
+
listing.reason = await sources.ghInstalled() ? "gh-unauthed" : "no-token";
|
|
56
|
+
return listing;
|
|
57
|
+
}
|
package/dist/relay-attach.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { EventTransferReceiver } from "./remote/event-transfer-receiver.js";
|
|
3
4
|
//
|
|
4
5
|
// Relay-tunnelled attach bridge for `bivy run --node <account-node>`.
|
|
5
6
|
//
|
|
@@ -155,7 +156,7 @@ async function main() {
|
|
|
155
156
|
throw new Error("no relay URL available for the target node");
|
|
156
157
|
// 3. open the relay /client socket in the target's room and pair.
|
|
157
158
|
const rly = new WebSocket(`${relayBase}/client?ticket=${encodeURIComponent(ticket)}&nodeId=${encodeURIComponent(args.nodeId)}`);
|
|
158
|
-
const reassembler = new FrameReassembler();
|
|
159
|
+
const reassembler = new FrameReassembler({ onReject: message => fail(message) });
|
|
159
160
|
let cipher = null;
|
|
160
161
|
let local = null;
|
|
161
162
|
let paired = false;
|
|
@@ -170,6 +171,18 @@ async function main() {
|
|
|
170
171
|
}
|
|
171
172
|
process.exit(code);
|
|
172
173
|
};
|
|
174
|
+
const transfers = new EventTransferReceiver({
|
|
175
|
+
send: async (command) => {
|
|
176
|
+
if (!cipher || rly.readyState !== WebSocket.OPEN)
|
|
177
|
+
throw new Error("Relay disconnected");
|
|
178
|
+
for (const frame of frameMessages(cipher.seal(command)))
|
|
179
|
+
rly.send(frame);
|
|
180
|
+
},
|
|
181
|
+
emit: event => { if (local?.readyState === WebSocket.OPEN)
|
|
182
|
+
local.send(JSON.stringify(event)); },
|
|
183
|
+
fault: message => fail(message),
|
|
184
|
+
});
|
|
185
|
+
rly.on("close", () => { transfers.reset(); reassembler.reset(); });
|
|
173
186
|
const pairTimer = setTimeout(() => {
|
|
174
187
|
if (!paired)
|
|
175
188
|
fail(`Node "${args.nodeName}" did not respond to pairing — it may be offline.`, 1);
|
|
@@ -258,8 +271,7 @@ async function main() {
|
|
|
258
271
|
return; // undecryptable frame — ignore
|
|
259
272
|
}
|
|
260
273
|
// Forward the bare terminal.* event to the local attach socket verbatim.
|
|
261
|
-
|
|
262
|
-
local.send(JSON.stringify(event));
|
|
274
|
+
transfers.accept(event);
|
|
263
275
|
}
|
|
264
276
|
});
|
|
265
277
|
// Stand up the loopback /ws server + spawn attach, only once paired.
|
|
@@ -293,8 +305,8 @@ async function main() {
|
|
|
293
305
|
rly.send(frame);
|
|
294
306
|
}
|
|
295
307
|
}
|
|
296
|
-
catch {
|
|
297
|
-
|
|
308
|
+
catch (error) {
|
|
309
|
+
sock.send(JSON.stringify({ type: "session.error", code: "delivery_failed", error: error.message }));
|
|
298
310
|
}
|
|
299
311
|
});
|
|
300
312
|
sock.on("close", () => {
|
package/dist/relay-chunk.js
CHANGED
|
@@ -11,6 +11,8 @@ export { FRAME_CHUNK_BYTES };
|
|
|
11
11
|
* chunk messages for large ones.
|
|
12
12
|
*/
|
|
13
13
|
export function frameMessages(payload) {
|
|
14
|
+
if (payload.length > MAX_REASSEMBLY_BYTES)
|
|
15
|
+
throw new Error("Relay payload exceeds the receiver limit; use a bounded transfer.");
|
|
14
16
|
if (payload.length <= FRAME_CHUNK_BYTES) {
|
|
15
17
|
return [JSON.stringify({ t: "frame", p: payload })];
|
|
16
18
|
}
|
|
@@ -23,51 +25,82 @@ export function frameMessages(payload) {
|
|
|
23
25
|
}
|
|
24
26
|
return out;
|
|
25
27
|
}
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
/** null means incomplete or rejected; onReject distinguishes delivery failure.
|
|
29
|
+
* Limits apply to aggregate buffering as well as individual messages. */
|
|
30
|
+
export function createFrameReassembler(opts = {}) {
|
|
31
|
+
const groups = new Map();
|
|
32
|
+
const rejected = new Map();
|
|
33
|
+
const maxGroups = opts.maxGroups ?? MAX_REASSEMBLY_GROUPS;
|
|
34
|
+
const maxBytes = opts.maxBytes ?? MAX_REASSEMBLY_BYTES;
|
|
35
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
36
|
+
let buffered = 0;
|
|
37
|
+
const remove = (id) => {
|
|
38
|
+
const group = groups.get(id);
|
|
39
|
+
if (group) {
|
|
40
|
+
clearTimeout(group.timer);
|
|
41
|
+
buffered -= group.bytes;
|
|
42
|
+
groups.delete(id);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const reject = (id, reason) => {
|
|
46
|
+
remove(id);
|
|
47
|
+
if (rejected.size >= 256)
|
|
48
|
+
rejected.delete(rejected.keys().next().value);
|
|
49
|
+
rejected.set(id, Date.now() + timeoutMs);
|
|
50
|
+
opts.onReject?.(reason);
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
const accept = (env) => {
|
|
54
|
+
for (const [id, expires] of rejected)
|
|
55
|
+
if (expires <= Date.now())
|
|
56
|
+
rejected.delete(id);
|
|
57
|
+
if (!env || typeof env.p !== "string")
|
|
39
58
|
return null;
|
|
40
59
|
if (env.fc === undefined)
|
|
41
|
-
return env.p
|
|
60
|
+
return env.p.length <= maxBytes ? env.p : reject("unchunked", "Relay response exceeds the receive limit.");
|
|
42
61
|
const id = String(env.fc);
|
|
43
|
-
|
|
44
|
-
const total = Number(env.fn);
|
|
45
|
-
if (!Number.isInteger(index) || !Number.isInteger(total) || total <= 0 || total > MAX_FRAME_CHUNKS || index < 0 || index >= total) {
|
|
62
|
+
if (rejected.has(id))
|
|
46
63
|
return null;
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
const i = Number(env.fi);
|
|
65
|
+
const n = Number(env.fn);
|
|
66
|
+
if (!Number.isInteger(i) || !Number.isInteger(n) || n <= 0 || n > MAX_FRAME_CHUNKS || i < 0 || i >= n)
|
|
67
|
+
return reject(id, "Invalid relay chunk metadata.");
|
|
68
|
+
let group = groups.get(id);
|
|
69
|
+
if (group && group.total !== n)
|
|
70
|
+
return reject(id, "Inconsistent relay chunk count.");
|
|
49
71
|
if (!group) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
group = { total, parts: new Array(total), have: 0, bytes: 0 };
|
|
57
|
-
this.groups.set(id, group);
|
|
72
|
+
if (groups.size >= maxGroups)
|
|
73
|
+
return reject(id, "Too many incomplete relay responses.");
|
|
74
|
+
const timer = setTimeout(() => reject(id, "Relay response timed out before all chunks arrived."), timeoutMs);
|
|
75
|
+
timer.unref?.();
|
|
76
|
+
group = { total: n, parts: new Array(n), have: 0, bytes: 0, timer };
|
|
77
|
+
groups.set(id, group);
|
|
58
78
|
}
|
|
59
|
-
if (group.parts[
|
|
60
|
-
return null; // duplicate chunk
|
|
61
|
-
group.parts[index] = env.p;
|
|
62
|
-
group.have += 1;
|
|
63
|
-
group.bytes += env.p.length;
|
|
64
|
-
if (group.bytes > MAX_REASSEMBLY_BYTES) {
|
|
65
|
-
this.groups.delete(id);
|
|
79
|
+
if (group.parts[i] !== undefined)
|
|
66
80
|
return null;
|
|
67
|
-
|
|
81
|
+
if (buffered + env.p.length > maxBytes)
|
|
82
|
+
return reject(id, "Relay responses exceed the receive memory limit.");
|
|
83
|
+
group.parts[i] = env.p;
|
|
84
|
+
group.have++;
|
|
85
|
+
group.bytes += env.p.length;
|
|
86
|
+
buffered += env.p.length;
|
|
68
87
|
if (group.have < group.total)
|
|
69
88
|
return null;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
const payload = group.parts.join("");
|
|
90
|
+
remove(id);
|
|
91
|
+
// Ignore straggling duplicates of a completed group instead of creating a
|
|
92
|
+
// new incomplete group that later reports a spurious timeout.
|
|
93
|
+
if (rejected.size >= 256)
|
|
94
|
+
rejected.delete(rejected.keys().next().value);
|
|
95
|
+
rejected.set(id, Date.now() + timeoutMs);
|
|
96
|
+
return payload;
|
|
97
|
+
};
|
|
98
|
+
return Object.assign(accept, { reset() { for (const id of groups.keys())
|
|
99
|
+
remove(id); rejected.clear(); } });
|
|
100
|
+
}
|
|
101
|
+
export class FrameReassembler {
|
|
102
|
+
acceptFrame;
|
|
103
|
+
constructor(options = {}) { this.acceptFrame = createFrameReassembler(options); }
|
|
104
|
+
accept(env) { return this.acceptFrame(env); }
|
|
105
|
+
reset() { this.acceptFrame.reset(); }
|
|
73
106
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Standalone node mirror of packages/core/src/event-transfer.ts. The release
|
|
4
|
+
// omits packages/; behavior is covered by the cross-stack transfer tests.
|
|
5
|
+
const unb64 = (data) => Uint8Array.from(atob(data), c => c.charCodeAt(0));
|
|
6
|
+
import { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES } from "../wire-format.js";
|
|
7
|
+
export { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES };
|
|
8
|
+
const MAX_QUEUED_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
/** Deliver complete events in wire order. A history snapshot and its baseline
|
|
10
|
+
* are never exposed partially; later live events wait behind that snapshot.
|
|
11
|
+
* Only one page is in flight, with bounded retries and memory. */
|
|
12
|
+
export class EventTransferReceiver {
|
|
13
|
+
deps;
|
|
14
|
+
queue = [];
|
|
15
|
+
queuedBytes = 0;
|
|
16
|
+
pending;
|
|
17
|
+
timer;
|
|
18
|
+
generation = 0;
|
|
19
|
+
constructor(deps) {
|
|
20
|
+
this.deps = deps;
|
|
21
|
+
}
|
|
22
|
+
accept(event) {
|
|
23
|
+
if (event.type === "transfer.part") {
|
|
24
|
+
void this.part(event);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (this.pending || this.queue.length) {
|
|
28
|
+
this.queuedBytes += new TextEncoder().encode(JSON.stringify(event)).length;
|
|
29
|
+
if (this.queuedBytes > MAX_QUEUED_BYTES || this.queue.length >= 4096) {
|
|
30
|
+
this.reset();
|
|
31
|
+
this.deps.fault("Transcript delivery fell behind. Reconnecting to synchronize again.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.queue.push(event);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.deliver(event);
|
|
38
|
+
}
|
|
39
|
+
deliver(event) {
|
|
40
|
+
if (event.code !== "transfer_required") {
|
|
41
|
+
this.deps.emit(event);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const offer = event.transfer;
|
|
45
|
+
if (!offer || !/^[a-f0-9]{48}$/.test(offer.id) || !/^[a-f0-9]{64}$/.test(offer.key) || !/^[a-f0-9]{24}$/.test(offer.iv) ||
|
|
46
|
+
!Number.isSafeInteger(offer.bytes) || offer.bytes <= 0 || offer.bytes > MAX_TRANSFER_BYTES || offer.pageBytes !== TRANSFER_PAGE_BYTES) {
|
|
47
|
+
this.deps.emit({ type: "session.error", code: "delivery_invalid", sessionId: event.sessionId, requestId: event.requestId, error: "Invalid large-response transfer. Update the app and retry." });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.pending = { offer, envelope: event, bytes: new Uint8Array(offer.bytes), offset: 0, requestId: "", retries: 0 };
|
|
51
|
+
this.request();
|
|
52
|
+
}
|
|
53
|
+
request() {
|
|
54
|
+
const pending = this.pending;
|
|
55
|
+
if (!pending)
|
|
56
|
+
return;
|
|
57
|
+
pending.requestId = `transfer-${crypto.randomUUID()}`;
|
|
58
|
+
clearTimeout(this.timer);
|
|
59
|
+
this.timer = setTimeout(() => {
|
|
60
|
+
if (this.pending !== pending)
|
|
61
|
+
return;
|
|
62
|
+
if (++pending.retries <= 2)
|
|
63
|
+
this.request();
|
|
64
|
+
else
|
|
65
|
+
this.fail("Large-response delivery timed out. Retry to synchronize.");
|
|
66
|
+
}, this.deps.timeoutMs ?? 10_000);
|
|
67
|
+
void this.deps.send({ kind: "transfer.read", transferId: pending.offer.id, offset: pending.offset, requestId: pending.requestId })
|
|
68
|
+
.catch(() => { if (this.pending === pending)
|
|
69
|
+
this.fail("Could not request the next response page. Retry to synchronize."); });
|
|
70
|
+
}
|
|
71
|
+
async part(event) {
|
|
72
|
+
const pending = this.pending;
|
|
73
|
+
// Other paired clients use the same encrypted room; their replies are not ours.
|
|
74
|
+
if (!pending || event.requestId !== pending.requestId || event.transferId !== pending.offer.id || event.offset !== pending.offset)
|
|
75
|
+
return;
|
|
76
|
+
clearTimeout(this.timer);
|
|
77
|
+
if (typeof event.error === "string") {
|
|
78
|
+
this.fail(event.error);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
if (typeof event.data !== "string" || event.data.length > Math.ceil(TRANSFER_PAGE_BYTES / 3) * 4)
|
|
83
|
+
throw new Error("Invalid response page.");
|
|
84
|
+
const bytes = unb64(event.data);
|
|
85
|
+
if (bytes.length !== Math.min(TRANSFER_PAGE_BYTES, pending.offer.bytes - pending.offset))
|
|
86
|
+
throw new Error("Incomplete response page.");
|
|
87
|
+
pending.bytes.set(bytes, pending.offset);
|
|
88
|
+
pending.offset += bytes.length;
|
|
89
|
+
pending.retries = 0;
|
|
90
|
+
if (pending.offset < pending.offer.bytes) {
|
|
91
|
+
this.request();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Reject duplicate final pages while the authenticated decryption is asynchronous.
|
|
95
|
+
pending.requestId = "";
|
|
96
|
+
const generation = this.generation;
|
|
97
|
+
const fromHex = (value) => Uint8Array.from(value.match(/../g), pair => parseInt(pair, 16));
|
|
98
|
+
const key = await crypto.subtle.importKey("raw", fromHex(pending.offer.key), "AES-GCM", false, ["decrypt"]);
|
|
99
|
+
let plaintext;
|
|
100
|
+
try {
|
|
101
|
+
plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromHex(pending.offer.iv) }, key, pending.bytes);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error("Response integrity check failed.");
|
|
105
|
+
}
|
|
106
|
+
if (generation !== this.generation || this.pending !== pending)
|
|
107
|
+
return;
|
|
108
|
+
const complete = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
|
|
109
|
+
if (!complete || typeof complete.type !== "string" || complete.code === "transfer_required" || complete.type === "transfer.part")
|
|
110
|
+
throw new Error("Invalid response envelope.");
|
|
111
|
+
this.pending = undefined;
|
|
112
|
+
this.deps.emit(complete);
|
|
113
|
+
this.drain();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (this.pending === pending)
|
|
117
|
+
this.fail(`${error.message} Retry to synchronize.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
fail(message) {
|
|
121
|
+
const envelope = this.pending?.envelope;
|
|
122
|
+
this.reset();
|
|
123
|
+
this.deps.emit({ type: "session.error", code: "delivery_failed", sessionId: envelope?.sessionId, requestId: envelope?.requestId, error: message });
|
|
124
|
+
// Dropping queued events invalidates the stream. Reconnect triggers the
|
|
125
|
+
// existing history/replay recovery; never apply a tail as a full snapshot.
|
|
126
|
+
this.deps.fault(message);
|
|
127
|
+
}
|
|
128
|
+
drain() {
|
|
129
|
+
while (!this.pending && this.queue.length) {
|
|
130
|
+
const next = this.queue.shift();
|
|
131
|
+
this.queuedBytes -= new TextEncoder().encode(JSON.stringify(next)).length;
|
|
132
|
+
this.deliver(next);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
reset() {
|
|
136
|
+
clearTimeout(this.timer);
|
|
137
|
+
this.pending = undefined;
|
|
138
|
+
this.queue = [];
|
|
139
|
+
this.queuedBytes = 0;
|
|
140
|
+
this.generation++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { createCipheriv, randomBytes } from "node:crypto";
|
|
4
|
+
// A logical event is immutable while clients pull its pages. Pages are encoded
|
|
5
|
+
// independently, so neither history nor an individual attachment must fit in a
|
|
6
|
+
// single sealed frame. Keep in sync with core/event-transfer.ts (conformance test).
|
|
7
|
+
import { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES } from "../wire-format.js";
|
|
8
|
+
export { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES };
|
|
9
|
+
const MAX_STORED_BYTES = 128 * 1024 * 1024;
|
|
10
|
+
const TTL_MS = 120_000;
|
|
11
|
+
const INLINE_EVENT_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
const MAX_LIFETIME_MS = 10 * 60_000;
|
|
13
|
+
export class EventTransferStore {
|
|
14
|
+
now;
|
|
15
|
+
inlineBytes;
|
|
16
|
+
entries = new Map();
|
|
17
|
+
bytes = 0;
|
|
18
|
+
timer;
|
|
19
|
+
constructor(now = Date.now, inlineBytes = INLINE_EVENT_BYTES) {
|
|
20
|
+
this.now = now;
|
|
21
|
+
this.inlineBytes = inlineBytes;
|
|
22
|
+
}
|
|
23
|
+
prune() {
|
|
24
|
+
for (const [id, entry] of this.entries)
|
|
25
|
+
if (entry.expires <= this.now()) {
|
|
26
|
+
this.bytes -= entry.body.length;
|
|
27
|
+
this.entries.delete(id);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
offer(event) {
|
|
31
|
+
const body = Buffer.from(JSON.stringify(event));
|
|
32
|
+
if (body.length <= this.inlineBytes)
|
|
33
|
+
return null;
|
|
34
|
+
const source = event;
|
|
35
|
+
const context = { sessionId: source.sessionId, requestId: source.requestId };
|
|
36
|
+
this.prune();
|
|
37
|
+
if (body.length + 16 > MAX_TRANSFER_BYTES)
|
|
38
|
+
return {
|
|
39
|
+
type: "session.error", ...context, code: "delivery_too_large",
|
|
40
|
+
error: "This response exceeds the machine's 64 MiB delivery limit. The original data is retained on the machine.",
|
|
41
|
+
};
|
|
42
|
+
// Retain only authenticated ciphertext. This is response encryption, not
|
|
43
|
+
// a password digest or a second durable copy of credential-bearing events.
|
|
44
|
+
if (this.bytes + body.length + 16 > MAX_STORED_BYTES || this.entries.size >= 16)
|
|
45
|
+
return {
|
|
46
|
+
type: "session.error", ...context, code: "delivery_busy", error: "The machine is serving large responses. Please retry shortly.",
|
|
47
|
+
};
|
|
48
|
+
const id = randomBytes(24).toString("hex");
|
|
49
|
+
const key = randomBytes(32);
|
|
50
|
+
const iv = randomBytes(12);
|
|
51
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
52
|
+
// WebCrypto's native AES-GCM layout is ciphertext followed by its tag.
|
|
53
|
+
const sealed = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()]);
|
|
54
|
+
this.entries.set(id, { body: sealed, key: key.toString("hex"), iv: iv.toString("hex"),
|
|
55
|
+
expires: this.now() + TTL_MS, deadline: this.now() + MAX_LIFETIME_MS });
|
|
56
|
+
this.bytes += sealed.length;
|
|
57
|
+
if (!this.timer) {
|
|
58
|
+
this.timer = setInterval(() => this.prune(), TTL_MS);
|
|
59
|
+
this.timer.unref();
|
|
60
|
+
}
|
|
61
|
+
// Legacy clients already display session.notice messages. Updated clients consume the
|
|
62
|
+
// transfer before the reducer sees this compatibility envelope.
|
|
63
|
+
return { type: "session.notice", ...context, code: "transfer_required",
|
|
64
|
+
message: "Update the Bivy app to load this large response.",
|
|
65
|
+
transfer: { id, bytes: sealed.length, key: key.toString("hex"), iv: iv.toString("hex"), pageBytes: TRANSFER_PAGE_BYTES } };
|
|
66
|
+
}
|
|
67
|
+
read(command) {
|
|
68
|
+
this.prune();
|
|
69
|
+
const { transferId, offset, requestId } = command;
|
|
70
|
+
const entry = typeof transferId === "string" ? this.entries.get(transferId) : undefined;
|
|
71
|
+
const base = { type: "transfer.part", transferId, requestId, offset };
|
|
72
|
+
if (!entry)
|
|
73
|
+
return { ...base, error: "This response expired. Retry to fetch a fresh snapshot." };
|
|
74
|
+
if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 || offset >= entry.body.length || offset % TRANSFER_PAGE_BYTES !== 0) {
|
|
75
|
+
return { ...base, error: "Invalid transfer offset." };
|
|
76
|
+
}
|
|
77
|
+
entry.expires = Math.min(entry.deadline, this.now() + TTL_MS);
|
|
78
|
+
return { ...base, data: entry.body.subarray(offset, offset + TRANSFER_PAGE_BYTES).toString("base64") };
|
|
79
|
+
}
|
|
80
|
+
clear() { clearInterval(this.timer); this.timer = undefined; this.entries.clear(); this.bytes = 0; }
|
|
81
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
+
import { EventTransferStore } from "./event-transfer.js";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { WebSocket } from "ws";
|
|
6
7
|
import { seal, sealFrame, openFrame, ReplayGuard } from "../e2e.js";
|
|
@@ -94,7 +95,11 @@ export class RelayConnector {
|
|
|
94
95
|
// explains why instead of silently showing "configured".
|
|
95
96
|
lastErrorMessage;
|
|
96
97
|
replay = new ReplayGuard();
|
|
97
|
-
|
|
98
|
+
transfers = new EventTransferStore();
|
|
99
|
+
reassembler = new FrameReassembler({ onReject: message => {
|
|
100
|
+
console.warn(`[relay] ${message}`);
|
|
101
|
+
this.sendEvent({ type: "session.error", code: "delivery_failed", error: message });
|
|
102
|
+
} });
|
|
98
103
|
pairing;
|
|
99
104
|
onWorkAvailable;
|
|
100
105
|
constructor(config, onClientMessage, options) {
|
|
@@ -135,6 +140,8 @@ export class RelayConnector {
|
|
|
135
140
|
void this.connect();
|
|
136
141
|
}
|
|
137
142
|
stop() {
|
|
143
|
+
this.transfers.clear();
|
|
144
|
+
this.reassembler.reset();
|
|
138
145
|
this.options.previews?.disconnect();
|
|
139
146
|
this.closed = true;
|
|
140
147
|
this.ready = false;
|
|
@@ -204,7 +211,8 @@ export class RelayConnector {
|
|
|
204
211
|
sendEvent(event) {
|
|
205
212
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
206
213
|
return;
|
|
207
|
-
const
|
|
214
|
+
const offered = event?.type === "transfer.part" ? null : this.transfers.offer(event);
|
|
215
|
+
const payload = sealFrame(this.roomKey(), offered ?? event);
|
|
208
216
|
// Large events (big file reads, long diffs, image attachments) are split
|
|
209
217
|
// into multiple relay frames so none exceeds the relay's max-frame limit;
|
|
210
218
|
// the client reassembles them before decrypting.
|
|
@@ -395,7 +403,11 @@ export class RelayConnector {
|
|
|
395
403
|
console.warn("[relay] dropped stale or replayed client frame");
|
|
396
404
|
return;
|
|
397
405
|
}
|
|
398
|
-
|
|
406
|
+
const command = frame.data;
|
|
407
|
+
if (command.kind === "transfer.read")
|
|
408
|
+
this.sendEvent(this.transfers.read(command));
|
|
409
|
+
else
|
|
410
|
+
this.onClientMessage(command);
|
|
399
411
|
}
|
|
400
412
|
catch {
|
|
401
413
|
console.warn("[relay] failed to decrypt client frame");
|
|
@@ -424,6 +436,7 @@ export class RelayConnector {
|
|
|
424
436
|
ws.on("close", () => {
|
|
425
437
|
if (this.ws !== ws)
|
|
426
438
|
return;
|
|
439
|
+
this.reassembler.reset();
|
|
427
440
|
this.options.previews?.disconnect();
|
|
428
441
|
this.ready = false;
|
|
429
442
|
this.remoteClients = 0;
|
package/dist/server.js
CHANGED
|
@@ -55,6 +55,7 @@ import { createSessionNamer, fallbackSessionName } from "./session/session-namer
|
|
|
55
55
|
import { createBranchPublish } from "./session/branch-publish.js";
|
|
56
56
|
import { createForkStandUp } from "./session/fork-standup.js";
|
|
57
57
|
import { createForkRetire } from "./session/fork-retire.js";
|
|
58
|
+
import { createClientProjection } from "./session/client-projection.js";
|
|
58
59
|
import { createTranscriptPersistence } from "./session/transcript-persistence.js";
|
|
59
60
|
import { createRunTerminals } from "./session/run-terminal.js";
|
|
60
61
|
import { createRunLogStore } from "./session/run-log-store.js";
|
|
@@ -143,6 +144,7 @@ import { SecretVault, resolveSecret } from "./secrets.js";
|
|
|
143
144
|
import { deviceFlowClientId, requestDeviceCode, pollAccessTokenOnce, REPO_CONNECT_SCOPE } from "./github-device-auth.js";
|
|
144
145
|
import { InstallationTokenCache, createAppJwt, resolveInstallationId } from "./github-app-auth.js";
|
|
145
146
|
import { listGithubAppRepos } from "./github-app-repos.js";
|
|
147
|
+
import { discoverGithubRepos, listGithubUserRepos } from "./github-repos.js";
|
|
146
148
|
import { loadGitHubAppConfigs, orderAppsForOwner, listGitHubApps, removeGitHubApp, upsertGitHubApp, privateKeyIdFor, } from "./github-apps.js";
|
|
147
149
|
import { buildAppManifest, convertManifest, renderManifestForm } from "./github-app-manifest.js";
|
|
148
150
|
import { encryptGithubAppEnvelope, decryptGithubAppEnvelope, readLocalGithubAppVaultKey, writeLocalGithubAppVaultKey, forgetLocalGithubAppVaultKey, mintLocalGithubAppVaultKey, } from "./github-app-vault.js";
|
|
@@ -1450,7 +1452,11 @@ const commands = [
|
|
|
1450
1452
|
// A local client whose send buffer has grown past this is behind on reads (slow
|
|
1451
1453
|
// network, background tab); used by both broadcast paths below.
|
|
1452
1454
|
const CLIENT_BACKPRESSURE_BYTES = 8 * 1024 * 1024;
|
|
1455
|
+
let projectClientMessage;
|
|
1453
1456
|
function broadcast(payload) {
|
|
1457
|
+
const p = payload;
|
|
1458
|
+
if (p?.type === "session.event" && p.sessionId && projectClientMessage)
|
|
1459
|
+
payload = projectClientMessage(p.sessionId, payload);
|
|
1454
1460
|
const data = JSON.stringify(payload);
|
|
1455
1461
|
for (const client of clients) {
|
|
1456
1462
|
if (client.readyState !== WebSocket.OPEN)
|
|
@@ -1481,6 +1487,9 @@ function broadcast(payload) {
|
|
|
1481
1487
|
// out as broadcast(), but skips any local client that is backed up: dropping a
|
|
1482
1488
|
// superseded update is lossless because a newer one always follows.
|
|
1483
1489
|
function broadcastCoalesced(payload) {
|
|
1490
|
+
const p = payload;
|
|
1491
|
+
if (p?.type === "session.event" && p.sessionId && projectClientMessage)
|
|
1492
|
+
payload = projectClientMessage(p.sessionId, payload);
|
|
1484
1493
|
const data = JSON.stringify(payload);
|
|
1485
1494
|
for (const client of clients) {
|
|
1486
1495
|
if (client.readyState !== WebSocket.OPEN)
|
|
@@ -1630,6 +1639,7 @@ const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"), {
|
|
|
1630
1639
|
maxStoreBytes: positiveEnvNumber("BIVY_ATTACHMENT_STORE_MAX_BYTES", 2 * 1024 * 1024 * 1024),
|
|
1631
1640
|
retentionMs: positiveEnvNumber("BIVY_ATTACHMENT_RETENTION_MS", 30 * 24 * 60 * 60 * 1000),
|
|
1632
1641
|
});
|
|
1642
|
+
projectClientMessage = createClientProjection(attachmentStore, eventLog);
|
|
1633
1643
|
let attachmentGcStats = attachmentStore.stats();
|
|
1634
1644
|
function referencedAttachmentHashes() {
|
|
1635
1645
|
// If transcript history is unreadable, collecting nothing would make its
|
|
@@ -10483,46 +10493,25 @@ async function createControlPlaneHook(kind = "github") {
|
|
|
10483
10493
|
throw new Error(data?.error || `Could not create Bivy webhook (${res.status})`);
|
|
10484
10494
|
return data;
|
|
10485
10495
|
}
|
|
10486
|
-
//
|
|
10487
|
-
//
|
|
10488
|
-
// token stored on this machine (env or `gh`). Returns `authed: false` with
|
|
10489
|
-
// an empty list when there's no token so the UI falls back to manual owner/repo.
|
|
10496
|
+
// Combine local App access with the configured user token or gh login, matching
|
|
10497
|
+
// the credentials available to clone/fetch. Cache only complete, successful lists.
|
|
10490
10498
|
async function listAccessibleRepos() {
|
|
10491
10499
|
if (reposCache && Date.now() - reposCache.at < REPO_LIST_TTL_MS)
|
|
10492
10500
|
return reposCache.val;
|
|
10493
|
-
|
|
10494
|
-
|
|
10495
|
-
|
|
10496
|
-
|
|
10497
|
-
|
|
10498
|
-
|
|
10499
|
-
|
|
10500
|
-
|
|
10501
|
-
|
|
10502
|
-
|
|
10503
|
-
|
|
10504
|
-
|
|
10505
|
-
});
|
|
10506
|
-
if (!ghRes.ok)
|
|
10507
|
-
return { authed: true, repos: [], error: `GitHub responded ${ghRes.status}` };
|
|
10508
|
-
const raw = (await ghRes.json().catch(() => []));
|
|
10509
|
-
const repos = (Array.isArray(raw) ? raw : []).map((r) => ({
|
|
10510
|
-
slug: String(r.full_name ?? ""),
|
|
10511
|
-
description: typeof r.description === "string" ? r.description : "",
|
|
10512
|
-
private: Boolean(r.private),
|
|
10513
|
-
pushedAt: typeof r.pushed_at === "string" ? r.pushed_at : undefined,
|
|
10514
|
-
// GitHub already hands us each repo's default branch here, so the branch
|
|
10515
|
-
// picker can label "Default branch (main)" instantly from the cached repo
|
|
10516
|
-
// list — no separate /repos/{owner}/{repo} round trip when it opens.
|
|
10517
|
-
defaultBranch: typeof r.default_branch === "string" ? r.default_branch : undefined,
|
|
10518
|
-
})).filter((r) => r.slug);
|
|
10519
|
-
const val = { authed: true, repos };
|
|
10501
|
+
const val = await discoverGithubRepos({
|
|
10502
|
+
appRepos: async () => {
|
|
10503
|
+
const apps = await ensureGitHubApps();
|
|
10504
|
+
return apps.length ? listGithubAppRepos(apps) : undefined;
|
|
10505
|
+
},
|
|
10506
|
+
userRepos: async () => {
|
|
10507
|
+
const token = await resolveGitHubToken();
|
|
10508
|
+
return token ? listGithubUserRepos(token) : undefined;
|
|
10509
|
+
},
|
|
10510
|
+
ghInstalled: ghCliInstalled,
|
|
10511
|
+
});
|
|
10512
|
+
if (val.authed && !val.error)
|
|
10520
10513
|
reposCache = { at: Date.now(), val };
|
|
10521
|
-
|
|
10522
|
-
}
|
|
10523
|
-
catch (error) {
|
|
10524
|
-
return { authed: false, repos: [], error: error instanceof Error ? error.message : String(error) };
|
|
10525
|
-
}
|
|
10514
|
+
return val;
|
|
10526
10515
|
}
|
|
10527
10516
|
let pendingGithubConnect = null;
|
|
10528
10517
|
async function startGithubConnect() {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
/** A display-only projection. Never edit runtime messages or persisted bases:
|
|
5
|
+
* the model still needs its native image blocks when a session resumes. */
|
|
6
|
+
export function createClientProjection(store, log) {
|
|
7
|
+
const refs = new Map();
|
|
8
|
+
return (sessionId, value) => {
|
|
9
|
+
let retained;
|
|
10
|
+
let changed = false;
|
|
11
|
+
function visit(value) {
|
|
12
|
+
if (!value || typeof value !== "object")
|
|
13
|
+
return value;
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return value.map(visit);
|
|
16
|
+
const block = value;
|
|
17
|
+
const source = block.source;
|
|
18
|
+
let data;
|
|
19
|
+
let mime;
|
|
20
|
+
if (block.type === "image") {
|
|
21
|
+
data = block.data ?? (source?.type === "base64" ? source.data : undefined);
|
|
22
|
+
mime = block.mimeType ?? source?.media_type;
|
|
23
|
+
}
|
|
24
|
+
else if (block.type === "image_url" || block.type === "input_image") {
|
|
25
|
+
const url = typeof block.image_url === "string" ? block.image_url : block.image_url?.url;
|
|
26
|
+
if (typeof url === "string") {
|
|
27
|
+
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([\s\S]*)$/.exec(url);
|
|
28
|
+
if (match) {
|
|
29
|
+
mime = match[1];
|
|
30
|
+
data = match[2];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof data === "string") {
|
|
35
|
+
try {
|
|
36
|
+
if (data.length > 36 * 1024 * 1024 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 === 1)
|
|
37
|
+
throw new Error("Invalid image encoding");
|
|
38
|
+
const mimeType = typeof mime === "string" && /^image\/[a-zA-Z0-9.+-]+$/.test(mime) ? mime : "image/png";
|
|
39
|
+
const cacheKey = createHash("sha256").update(mimeType).update(data).digest("hex");
|
|
40
|
+
let ref = refs.get(cacheKey);
|
|
41
|
+
if (!ref || !store.getPath(ref.hash)) {
|
|
42
|
+
ref = store.put(Buffer.from(data, "base64"), { name: `image.${mimeType.split("/")[1]}`, mimeType, kind: "image" });
|
|
43
|
+
if (refs.size >= 1024)
|
|
44
|
+
refs.delete(refs.keys().next().value);
|
|
45
|
+
refs.set(cacheKey, ref);
|
|
46
|
+
}
|
|
47
|
+
const url = `bivy-embedded:${ref.hash}`;
|
|
48
|
+
retained ??= new Set(log.readInlineImages(sessionId).map(([url]) => url));
|
|
49
|
+
if (!retained.has(url)) {
|
|
50
|
+
log.appendInlineImage(sessionId, { url, ref });
|
|
51
|
+
retained.add(url);
|
|
52
|
+
changed = true;
|
|
53
|
+
}
|
|
54
|
+
return { type: "bivy_attachment", ref };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Failure is visible in the display, while original data remains in
|
|
58
|
+
// the native transcript. Never fall back to sending the huge bytes.
|
|
59
|
+
return { type: "text", text: "[Image unavailable: could not store this image for display. The original is retained on the machine.]" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Only recognized image blocks are rewritten. Arbitrary strings and tool
|
|
63
|
+
// arguments remain byte-for-byte intact, including large textual results.
|
|
64
|
+
return Object.fromEntries(Object.entries(block).map(([key, child]) => [key, visit(child)]));
|
|
65
|
+
}
|
|
66
|
+
const projected = visit(value);
|
|
67
|
+
if (changed)
|
|
68
|
+
log.flush(sessionId);
|
|
69
|
+
return projected;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { EventTransferReceiver } from "../remote/event-transfer-receiver.js";
|
|
3
4
|
//
|
|
4
5
|
// A NODE acting as a relay CLIENT of a sibling node it co-owns — the transport
|
|
5
6
|
// that lets an owner daemon stream replication frames to its standby
|
|
@@ -30,7 +31,12 @@ export class SiblingClient {
|
|
|
30
31
|
ws;
|
|
31
32
|
cipher;
|
|
32
33
|
keypair = newDeviceKeypair();
|
|
33
|
-
reassembler = new FrameReassembler();
|
|
34
|
+
reassembler = new FrameReassembler({ onReject: () => this.ws?.close() });
|
|
35
|
+
transfers = new EventTransferReceiver({
|
|
36
|
+
send: async (command) => this.sendSealed(command),
|
|
37
|
+
emit: event => this.dispatchEvent(event),
|
|
38
|
+
fault: () => this.ws?.close(),
|
|
39
|
+
});
|
|
34
40
|
pending = new Map();
|
|
35
41
|
paired;
|
|
36
42
|
resolvePaired;
|
|
@@ -101,7 +107,7 @@ export class SiblingClient {
|
|
|
101
107
|
return;
|
|
102
108
|
try {
|
|
103
109
|
const frame = this.cipher.open(full);
|
|
104
|
-
this.
|
|
110
|
+
this.transfers.accept(frame.data);
|
|
105
111
|
}
|
|
106
112
|
catch {
|
|
107
113
|
/* undecryptable frame — ignore */
|
|
@@ -179,6 +185,8 @@ export class SiblingClient {
|
|
|
179
185
|
return (await res.json());
|
|
180
186
|
}
|
|
181
187
|
handleClose() {
|
|
188
|
+
this.transfers.reset();
|
|
189
|
+
this.reassembler.reset();
|
|
182
190
|
if (this.closed)
|
|
183
191
|
return;
|
|
184
192
|
for (const [, waiter] of this.pending) {
|
|
@@ -190,6 +198,8 @@ export class SiblingClient {
|
|
|
190
198
|
this.opts.onClose?.();
|
|
191
199
|
}
|
|
192
200
|
close() {
|
|
201
|
+
this.transfers.reset();
|
|
202
|
+
this.reassembler.reset();
|
|
193
203
|
this.closed = true;
|
|
194
204
|
try {
|
|
195
205
|
this.ws?.close();
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// so its logic is unit-testable without a live daemon. The EventLog /
|
|
14
14
|
// AttachmentStore singletons stay server-owned (GC, delete, replication use them)
|
|
15
15
|
// and are injected.
|
|
16
|
+
import { createClientProjection } from "./client-projection.js";
|
|
16
17
|
import { randomBytes } from "node:crypto";
|
|
17
18
|
import { mergeBases } from "./event-log.js";
|
|
18
19
|
import { thinkingTextFromContent } from "./transcript-merge.js";
|
|
@@ -72,6 +73,7 @@ function thinkingTextFromEvent(event) {
|
|
|
72
73
|
const INLINE_IMAGE_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
|
|
73
74
|
export function createTranscriptPersistence(deps) {
|
|
74
75
|
const { eventLog, attachmentStore } = deps;
|
|
76
|
+
const project = createClientProjection(attachmentStore, eventLog);
|
|
75
77
|
const liveIntermediateBySession = new Map();
|
|
76
78
|
const lastPersistedIntermediateText = new Map();
|
|
77
79
|
// In-flight dedupe + failure cooldown so a repeated remote image URL only ever
|
|
@@ -204,7 +206,8 @@ export function createTranscriptPersistence(deps) {
|
|
|
204
206
|
return eventLog.deriveHistory(record.id, record.session.getMessages());
|
|
205
207
|
}
|
|
206
208
|
function buildHistoryEvent(opts) {
|
|
207
|
-
const
|
|
209
|
+
const messages = opts.sessionId ? project(opts.sessionId, opts.messages) : opts.messages;
|
|
210
|
+
const delta = historyDelta(messages, opts.cursor);
|
|
208
211
|
const record = opts.sessionId ? deps.getOpenSession(opts.sessionId) : undefined;
|
|
209
212
|
const bSess = record ? deps.bivySessionEnvelope(record) : undefined;
|
|
210
213
|
return {
|
|
@@ -244,7 +247,7 @@ export function createTranscriptPersistence(deps) {
|
|
|
244
247
|
epoch: deps.streamEpoch,
|
|
245
248
|
mode: outcome.mode,
|
|
246
249
|
head: outcome.head,
|
|
247
|
-
events: outcome.mode === "replay" ? outcome.events : [],
|
|
250
|
+
events: outcome.mode === "replay" ? project(sessionId, outcome.events) : [],
|
|
248
251
|
};
|
|
249
252
|
}
|
|
250
253
|
return {
|
package/dist/wire-format.js
CHANGED
|
@@ -69,3 +69,6 @@ export const FRAME_CHUNK_BYTES = 192 * 1024;
|
|
|
69
69
|
export const MAX_REASSEMBLY_BYTES = 32 * 1024 * 1024;
|
|
70
70
|
export const MAX_FRAME_CHUNKS = 4096;
|
|
71
71
|
export const MAX_REASSEMBLY_GROUPS = 16;
|
|
72
|
+
/** Independently sealed large-response pages and maximum logical JSON size. */
|
|
73
|
+
export const TRANSFER_PAGE_BYTES = 384 * 1024;
|
|
74
|
+
export const MAX_TRANSFER_BYTES = 64 * 1024 * 1024;
|
package/package.json
CHANGED