baychat 0.8.1 → 0.9.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/README.md +43 -0
- package/dist/client-config-writer.js +149 -0
- package/dist/client-paths.js +84 -0
- package/dist/commands.js +8 -4
- package/dist/connect-plan.js +122 -0
- package/dist/connect.js +219 -0
- package/dist/index.js +58 -1
- package/dist/mcp-config.js +20 -116
- package/dist/mcp-dialects.js +143 -0
- package/dist/relay/adapters.js +130 -0
- package/dist/relay/commands.js +312 -0
- package/dist/relay/daemon.js +301 -0
- package/dist/relay/queue.js +108 -0
- package/dist/relay/registry.js +151 -0
- package/dist/relay/socket.js +125 -0
- package/dist/relay/types.js +11 -0
- package/dist/relay/updates.js +131 -0
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SessionQueue = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Per-session serialisation.
|
|
6
|
+
*
|
|
7
|
+
* The hard invariant: for any one session there is at most ONE delivery in
|
|
8
|
+
* flight at a time. Two concurrent wakes for the same session means two turns
|
|
9
|
+
* answering the same room from the same identity — an interactive turn and a
|
|
10
|
+
* headless resume racing, each unaware of the other, both replying. That is the
|
|
11
|
+
* failure this class exists to prevent, and it is why every wake path goes
|
|
12
|
+
* through `push` rather than calling the adapter directly.
|
|
13
|
+
*
|
|
14
|
+
* Messages that arrive while a turn is running are not dropped and do not
|
|
15
|
+
* spawn a second turn: they accumulate and are handed to the next delivery as
|
|
16
|
+
* one batch. Coalescing matters because a headless resume is expensive — three
|
|
17
|
+
* messages arriving during one run should produce one follow-up turn, not
|
|
18
|
+
* three.
|
|
19
|
+
*/
|
|
20
|
+
class SessionQueue {
|
|
21
|
+
deliver;
|
|
22
|
+
onError;
|
|
23
|
+
/** Buffered messages per session, awaiting the next free slot. */
|
|
24
|
+
buffers = new Map();
|
|
25
|
+
/** Sessions with a delivery currently in flight. */
|
|
26
|
+
running = new Set();
|
|
27
|
+
/** Resolvers for `idle()`, so tests and shutdown can await quiescence. */
|
|
28
|
+
idleWaiters = [];
|
|
29
|
+
constructor(
|
|
30
|
+
/**
|
|
31
|
+
* Delivers one batch. Must not throw for ordinary failures — a failed
|
|
32
|
+
* delivery is reported through DeliveryOutcome, not by rejecting. A
|
|
33
|
+
* rejection is treated as a crashed turn: it is logged by the caller's
|
|
34
|
+
* handler and the slot is freed so the session isn't wedged forever.
|
|
35
|
+
*/
|
|
36
|
+
deliver, onError = () => { }) {
|
|
37
|
+
this.deliver = deliver;
|
|
38
|
+
this.onError = onError;
|
|
39
|
+
}
|
|
40
|
+
/** Queue a message for a session, starting a delivery if none is running. */
|
|
41
|
+
push(session, message) {
|
|
42
|
+
const buf = this.buffers.get(session) ?? [];
|
|
43
|
+
// De-dupe inside the buffer too: the same event can legitimately arrive
|
|
44
|
+
// twice across a cursor re-baseline (409 recovery re-reads from a
|
|
45
|
+
// watermark), and a batch must not show the room the same line twice.
|
|
46
|
+
if (!buf.some((m) => m.id === message.id)) {
|
|
47
|
+
buf.push(message);
|
|
48
|
+
this.buffers.set(session, buf);
|
|
49
|
+
}
|
|
50
|
+
void this.drain(session);
|
|
51
|
+
}
|
|
52
|
+
async drain(session) {
|
|
53
|
+
if (this.running.has(session))
|
|
54
|
+
return; // a turn owns this session — it will pick the buffer up
|
|
55
|
+
const buf = this.buffers.get(session);
|
|
56
|
+
if (!buf || buf.length === 0)
|
|
57
|
+
return;
|
|
58
|
+
this.running.add(session);
|
|
59
|
+
try {
|
|
60
|
+
// Loop rather than recurse: messages that land *during* the delivery are
|
|
61
|
+
// picked up here without releasing the slot in between, so there is no
|
|
62
|
+
// window where a second drain could start a concurrent turn.
|
|
63
|
+
while (true) {
|
|
64
|
+
const batch = this.buffers.get(session) ?? [];
|
|
65
|
+
if (batch.length === 0)
|
|
66
|
+
break;
|
|
67
|
+
this.buffers.delete(session);
|
|
68
|
+
try {
|
|
69
|
+
await this.deliver(session, batch);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// A thrown delivery must not strand the session. Report and move on:
|
|
73
|
+
// the batch is already consumed, and retrying it blindly risks
|
|
74
|
+
// double-answering if the turn actually ran.
|
|
75
|
+
this.onError(session, err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
this.running.delete(session);
|
|
81
|
+
this.buffers.delete(session);
|
|
82
|
+
this.notifyIfIdle();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** True while any session has a delivery in flight. */
|
|
86
|
+
get busy() {
|
|
87
|
+
return this.running.size > 0;
|
|
88
|
+
}
|
|
89
|
+
/** Sessions with a delivery in flight — surfaced by `relay status`. */
|
|
90
|
+
activeSessions() {
|
|
91
|
+
return [...this.running];
|
|
92
|
+
}
|
|
93
|
+
/** Resolves once every in-flight delivery has finished. */
|
|
94
|
+
idle() {
|
|
95
|
+
if (!this.busy)
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
return new Promise((resolve) => this.idleWaiters.push(resolve));
|
|
98
|
+
}
|
|
99
|
+
notifyIfIdle() {
|
|
100
|
+
if (this.busy)
|
|
101
|
+
return;
|
|
102
|
+
const waiters = this.idleWaiters;
|
|
103
|
+
this.idleWaiters = [];
|
|
104
|
+
for (const w of waiters)
|
|
105
|
+
w();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
exports.SessionQueue = SessionQueue;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SessionRegistry = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const config_1 = require("../config");
|
|
40
|
+
const FILE = "relay-sessions.json";
|
|
41
|
+
function registryPath() {
|
|
42
|
+
return path.join((0, config_1.configDir)(), FILE);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The set of local sessions the relay can wake, keyed by BayChat session name.
|
|
46
|
+
*
|
|
47
|
+
* Persisted because `resumeId` is the difference between a headless wake that
|
|
48
|
+
* continues the right conversation and one that is refused as `pending`. A
|
|
49
|
+
* daemon restart (or a reboot) must not silently downgrade every session to
|
|
50
|
+
* "cannot resume safely" — so what `attach` learned is written to disk.
|
|
51
|
+
*
|
|
52
|
+
* `attached` is deliberately NOT persisted: it describes a live socket, and a
|
|
53
|
+
* process that just started holds none. Loading it as `true` would make the
|
|
54
|
+
* daemon believe someone is listening and route wakes into nothing.
|
|
55
|
+
*/
|
|
56
|
+
class SessionRegistry {
|
|
57
|
+
filePath;
|
|
58
|
+
sessions = new Map();
|
|
59
|
+
constructor(filePath = registryPath()) {
|
|
60
|
+
this.filePath = filePath;
|
|
61
|
+
}
|
|
62
|
+
load() {
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
|
65
|
+
if (!raw || typeof raw !== "object")
|
|
66
|
+
return;
|
|
67
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
68
|
+
if (!value || typeof value !== "object")
|
|
69
|
+
continue;
|
|
70
|
+
this.sessions.set(name, { ...value, name, attached: false });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Missing or corrupt — an empty registry is the correct reading. Every
|
|
75
|
+
// wake then reports `pending` with a reason, which is visible in
|
|
76
|
+
// `relay status`, rather than failing silently.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
save() {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const [name, target] of this.sessions) {
|
|
82
|
+
const { attached: _attached, ...persisted } = target;
|
|
83
|
+
out[name] = persisted;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
87
|
+
fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A registry we cannot persist still works for this process's lifetime;
|
|
91
|
+
// losing it costs resume ids after a restart, not correctness now.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Record (or update) a target. Merges rather than replaces: an `attach` that
|
|
96
|
+
* omits `resumeId` must not erase one an earlier attach established, or the
|
|
97
|
+
* next detached wake would report `pending` for a session we can in fact
|
|
98
|
+
* resume.
|
|
99
|
+
*/
|
|
100
|
+
upsert(target) {
|
|
101
|
+
const existing = this.sessions.get(target.name);
|
|
102
|
+
const merged = {
|
|
103
|
+
registeredAt: existing?.registeredAt ?? new Date().toISOString(),
|
|
104
|
+
...existing,
|
|
105
|
+
...stripUndefined(target),
|
|
106
|
+
name: target.name,
|
|
107
|
+
runtime: target.runtime,
|
|
108
|
+
};
|
|
109
|
+
this.sessions.set(target.name, merged);
|
|
110
|
+
this.save();
|
|
111
|
+
return merged;
|
|
112
|
+
}
|
|
113
|
+
get(name) {
|
|
114
|
+
return this.sessions.get(name);
|
|
115
|
+
}
|
|
116
|
+
all() {
|
|
117
|
+
return [...this.sessions.values()];
|
|
118
|
+
}
|
|
119
|
+
setAttached(name, attached) {
|
|
120
|
+
const t = this.sessions.get(name);
|
|
121
|
+
if (t)
|
|
122
|
+
t.attached = attached;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Drop targets whose session is no longer live server-side.
|
|
126
|
+
*
|
|
127
|
+
* A session ended from the app (Settings → Devices → Sessions → End) stops
|
|
128
|
+
* appearing in the device poll. Keeping a local target for it would leave a
|
|
129
|
+
* headless resume firing into a terminal the owner deliberately parked.
|
|
130
|
+
*
|
|
131
|
+
* Attached sessions are never pruned: a live socket outranks a poll, which
|
|
132
|
+
* may simply have raced a join that hasn't been stamped live yet.
|
|
133
|
+
*/
|
|
134
|
+
pruneToLive(liveNames) {
|
|
135
|
+
const live = new Set(liveNames);
|
|
136
|
+
const dropped = [];
|
|
137
|
+
for (const [name, target] of this.sessions) {
|
|
138
|
+
if (!live.has(name) && !target.attached) {
|
|
139
|
+
this.sessions.delete(name);
|
|
140
|
+
dropped.push(name);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (dropped.length > 0)
|
|
144
|
+
this.save();
|
|
145
|
+
return dropped;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
exports.SessionRegistry = SessionRegistry;
|
|
149
|
+
function stripUndefined(obj) {
|
|
150
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
151
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.socketPath = socketPath;
|
|
37
|
+
exports.pidFilePath = pidFilePath;
|
|
38
|
+
exports.createFrameReader = createFrameReader;
|
|
39
|
+
exports.writeFrame = writeFrame;
|
|
40
|
+
exports.probeSocket = probeSocket;
|
|
41
|
+
exports.unlinkStaleSocket = unlinkStaleSocket;
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const net = __importStar(require("net"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const config_1 = require("../config");
|
|
46
|
+
/**
|
|
47
|
+
* Where the attach socket lives.
|
|
48
|
+
*
|
|
49
|
+
* `XDG_RUNTIME_DIR` is the right home: it is user-private (0700), on tmpfs, and
|
|
50
|
+
* cleared on logout, so a stale socket never outlives the session that made it.
|
|
51
|
+
* The config-dir fallback covers boxes without it (some containers, macOS).
|
|
52
|
+
*/
|
|
53
|
+
function socketPath() {
|
|
54
|
+
const override = process.env.BAYCHAT_RELAY_SOCKET;
|
|
55
|
+
if (override)
|
|
56
|
+
return override;
|
|
57
|
+
const runtimeDir = process.env.XDG_RUNTIME_DIR;
|
|
58
|
+
if (runtimeDir)
|
|
59
|
+
return path.join(runtimeDir, "baychat-relay.sock");
|
|
60
|
+
return path.join((0, config_1.configDir)(), "relay.sock");
|
|
61
|
+
}
|
|
62
|
+
function pidFilePath() {
|
|
63
|
+
return path.join((0, config_1.configDir)(), "relay.pid");
|
|
64
|
+
}
|
|
65
|
+
/** Frames are newline-delimited JSON; this splits a stream into whole ones. */
|
|
66
|
+
function createFrameReader(onFrame, onBad) {
|
|
67
|
+
let buffer = "";
|
|
68
|
+
return (chunk) => {
|
|
69
|
+
buffer += chunk.toString();
|
|
70
|
+
// A peer that never sends a newline must not grow this unboundedly.
|
|
71
|
+
if (buffer.length > 1_000_000) {
|
|
72
|
+
buffer = "";
|
|
73
|
+
onBad?.("frame too large");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let idx;
|
|
77
|
+
while ((idx = buffer.indexOf("\n")) >= 0) {
|
|
78
|
+
const line = buffer.slice(0, idx).trim();
|
|
79
|
+
buffer = buffer.slice(idx + 1);
|
|
80
|
+
if (!line)
|
|
81
|
+
continue;
|
|
82
|
+
try {
|
|
83
|
+
onFrame(JSON.parse(line));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
onBad?.(line);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function writeFrame(sock, frame) {
|
|
92
|
+
sock.write(JSON.stringify(frame) + "\n");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Is a daemon already listening?
|
|
96
|
+
*
|
|
97
|
+
* A socket file on disk proves nothing — a killed daemon leaves one behind. The
|
|
98
|
+
* only honest test is to connect: ECONNREFUSED means the file is stale and safe
|
|
99
|
+
* to unlink, which is what lets `relay start` recover from a hard kill without
|
|
100
|
+
* a human deleting files.
|
|
101
|
+
*/
|
|
102
|
+
function probeSocket(sockPath, timeoutMs = 1_000) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
if (!fs.existsSync(sockPath))
|
|
105
|
+
return resolve(false);
|
|
106
|
+
const sock = net.createConnection(sockPath);
|
|
107
|
+
const done = (alive) => {
|
|
108
|
+
sock.destroy();
|
|
109
|
+
resolve(alive);
|
|
110
|
+
};
|
|
111
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
112
|
+
sock.on("connect", () => done(true));
|
|
113
|
+
sock.on("error", () => done(false));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Remove a socket file we have already proven dead. */
|
|
117
|
+
function unlinkStaleSocket(sockPath) {
|
|
118
|
+
try {
|
|
119
|
+
fs.unlinkSync(sockPath);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Already gone, or not ours to remove — the listen() that follows will
|
|
123
|
+
// surface anything that actually matters.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared types for the relay daemon.
|
|
4
|
+
*
|
|
5
|
+
* The relay's job is narrow: hold `/updates`, decide which local session an
|
|
6
|
+
* event belongs to, and wake that session — once, never twice concurrently.
|
|
7
|
+
* It deliberately does NOT decide whether the session should reply. That
|
|
8
|
+
* judgement is the server's (`shouldRespond`) and the woken session's; the
|
|
9
|
+
* relay only carries the message across the process boundary.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runUpdatesLoop = runUpdatesLoop;
|
|
4
|
+
const api_1 = require("../api");
|
|
5
|
+
/** Server clamps `wait` into [1s, 30s]; 25s stays under the usual 60s proxy idle timeout. */
|
|
6
|
+
const WAIT_SECONDS = 25;
|
|
7
|
+
/** Backoff bounds for transient failures (5xx, 429, network). */
|
|
8
|
+
const BACKOFF_MIN_MS = 1_000;
|
|
9
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
10
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
/**
|
|
12
|
+
* Transient failures are ridden out, not surfaced. A 502 while the api
|
|
13
|
+
* container restarts, a network throw, a 429 — all mean "later", not "stop".
|
|
14
|
+
* Real 4xx (401 expired device credential, 403) are terminal: they need a
|
|
15
|
+
* human, and retrying them forever would hide that.
|
|
16
|
+
*
|
|
17
|
+
* 409 is deliberately NOT here — it has its own recovery path and must never
|
|
18
|
+
* be treated as a generic hiccup, because backing off on it would leave the
|
|
19
|
+
* cursor permanently unusable and the relay silently deaf.
|
|
20
|
+
*/
|
|
21
|
+
function isTransient(err) {
|
|
22
|
+
if (err instanceof api_1.ApiError)
|
|
23
|
+
return err.status >= 500 || err.status === 429;
|
|
24
|
+
return err instanceof TypeError; // fetch rejects with TypeError on network failure
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Holds `GET /api/device-api/updates` and calls `onEvents` for every batch,
|
|
28
|
+
* until `signal` aborts.
|
|
29
|
+
*
|
|
30
|
+
* The credential is the machine's DEVICE credential, not an agent token. A
|
|
31
|
+
* laptop has one device login and its sessions mint their own agents when they
|
|
32
|
+
* join, so no single agent token can see them all — the server fans out across
|
|
33
|
+
* the device's live sessions and tags each event with the session it belongs to.
|
|
34
|
+
*
|
|
35
|
+
* Cursor discipline: the cursor is an opaque blob (really one cursor per session
|
|
36
|
+
* agent, encoded server-side) and advances only after a batch has been handed to
|
|
37
|
+
* `onEvents`. The caller queues synchronously, so a crash between poll and queue
|
|
38
|
+
* costs a re-read — harmless, ids de-dupe — rather than a lost message.
|
|
39
|
+
*
|
|
40
|
+
* On 409 `cursor_expired` recovery is ours: the server holds no record of what
|
|
41
|
+
* this client consumed. We re-read each watched conversation from our own
|
|
42
|
+
* watermark, then poll again with no cursor.
|
|
43
|
+
*/
|
|
44
|
+
async function runUpdatesLoop(opts) {
|
|
45
|
+
const { auth, watermarks, onEvents, onSessions, onPoll, onError, signal, request = api_1.apiRequest } = opts;
|
|
46
|
+
let cursor;
|
|
47
|
+
let backoff = BACKOFF_MIN_MS;
|
|
48
|
+
while (!signal.aborted) {
|
|
49
|
+
try {
|
|
50
|
+
const q = new URLSearchParams({ wait: String(WAIT_SECONDS) });
|
|
51
|
+
if (cursor)
|
|
52
|
+
q.set("cursor", cursor);
|
|
53
|
+
const res = await request(auth, "GET", `/api/device-api/updates?${q.toString()}`);
|
|
54
|
+
onSessions?.(res.sessions ?? []);
|
|
55
|
+
if (res.events.length > 0) {
|
|
56
|
+
onEvents(res.events.map((e) => ({
|
|
57
|
+
conversationId: e.conversationId,
|
|
58
|
+
sessionName: e.sessionName,
|
|
59
|
+
message: { ...e.message, conversationId: e.conversationId },
|
|
60
|
+
})));
|
|
61
|
+
}
|
|
62
|
+
cursor = res.cursor;
|
|
63
|
+
onPoll?.(res.cursor);
|
|
64
|
+
backoff = BACKOFF_MIN_MS; // a clean poll resets the ladder
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (signal.aborted)
|
|
68
|
+
return;
|
|
69
|
+
if (err instanceof api_1.ApiError && err.status === 409) {
|
|
70
|
+
onError?.(err, true);
|
|
71
|
+
try {
|
|
72
|
+
const recovered = await catchUp(auth, watermarks, request);
|
|
73
|
+
if (recovered.length > 0)
|
|
74
|
+
onEvents(recovered);
|
|
75
|
+
}
|
|
76
|
+
catch (catchUpErr) {
|
|
77
|
+
// Catch-up itself failed. Do NOT clear the cursor and carry on as if
|
|
78
|
+
// recovered — that is the silent-gap case. Back off and let the next
|
|
79
|
+
// 409 retry the whole recovery.
|
|
80
|
+
onError?.(catchUpErr, true);
|
|
81
|
+
await sleep(backoff);
|
|
82
|
+
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
cursor = undefined;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!isTransient(err)) {
|
|
89
|
+
onError?.(err, false);
|
|
90
|
+
throw err; // terminal — an expired device credential needs `npx baychat login`
|
|
91
|
+
}
|
|
92
|
+
onError?.(err, true);
|
|
93
|
+
await sleep(backoff);
|
|
94
|
+
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Re-read each watched conversation from its watermark. Used only on 409
|
|
100
|
+
* recovery. Messages we already delivered come back here; the caller's
|
|
101
|
+
* per-session queue de-dupes them by id, so a replay is cheap rather than
|
|
102
|
+
* duplicated into the room.
|
|
103
|
+
*
|
|
104
|
+
* A 404 for one conversation is skipped rather than fatal: a session may have
|
|
105
|
+
* ended or left the room between the poll and the recovery, and one dead room
|
|
106
|
+
* must not strand the catch-up for every other.
|
|
107
|
+
*/
|
|
108
|
+
async function catchUp(auth, watermarks, request) {
|
|
109
|
+
const events = [];
|
|
110
|
+
for (const [conversationId, since] of watermarks) {
|
|
111
|
+
let res;
|
|
112
|
+
try {
|
|
113
|
+
res = await request(auth, "GET", `/api/device-api/conversations/${conversationId}/messages?since=${encodeURIComponent(since)}`);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (err instanceof api_1.ApiError && err.status === 404)
|
|
117
|
+
continue;
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
for (const m of res.messages) {
|
|
121
|
+
if (m.deletedAt)
|
|
122
|
+
continue;
|
|
123
|
+
events.push({
|
|
124
|
+
conversationId,
|
|
125
|
+
sessionName: res.sessionName,
|
|
126
|
+
message: { ...m, conversationId },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return events;
|
|
131
|
+
}
|