replica-bridge 0.3.14 → 0.3.17
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/package.json +1 -1
- package/src/bridge/board.js +111 -15
- package/src/protocol.js +3 -1
package/package.json
CHANGED
package/src/bridge/board.js
CHANGED
|
@@ -23,7 +23,14 @@ const CATCH_UP_PAGES = 1000;
|
|
|
23
23
|
/** More changed tasks than this in one event, and the phone reads the whole board instead. */
|
|
24
24
|
export const CHANGED_ITEMS_MAX = 200;
|
|
25
25
|
/** What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). */
|
|
26
|
-
export const BOARD_CAPS = ["post", "delta"] ;
|
|
26
|
+
export const BOARD_CAPS = ["post", "delta", "items", "tasks"] ;
|
|
27
|
+
/** Up to this many changed tasks in one event come with their contents (`task`); past it the phone reads them itself. */
|
|
28
|
+
export const CARRIED_TASKS_MAX = 50;
|
|
29
|
+
/** A task bigger than this is left for the phone to read. */
|
|
30
|
+
const CARRIED_TASK_MAX_BYTES = 256 * 1024;
|
|
31
|
+
|
|
32
|
+
/** What the backend says a changed task now is: its view, or gone (404). Missing when the read failed. */
|
|
33
|
+
|
|
27
34
|
|
|
28
35
|
/** The backend's own cap on a request body (agens-backend src/http.mjs BODY_MAX). */
|
|
29
36
|
export const POST_BODY_MAX_BYTES = 16 * 1024;
|
|
@@ -31,10 +38,11 @@ const REC = "[A-Za-z0-9_.:-]+";
|
|
|
31
38
|
|
|
32
39
|
/**
|
|
33
40
|
* GETs (#367). /session also writes TASK.md in the task's working folder, which is what 开工 asks for; /share and /me
|
|
34
|
-
* only read.
|
|
41
|
+
* only read. From 0.3.16 also one item, a task's source (`?offset=` pages its text), as the backend's own page opens it.
|
|
42
|
+
* Not /assist, /mcp, /search, /context, /changes (the bridge's own poll) or anything under /ui.
|
|
35
43
|
*/
|
|
36
44
|
const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, new RegExp(`^/v1/recommendations/${REC}$`),
|
|
37
|
-
new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me
|
|
45
|
+
new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/, new RegExp(`^/v1/items/${REC}$`)];
|
|
38
46
|
|
|
39
47
|
/** POSTs (#367): a task's follow-up actions, cancelling its preparation note, and muting a conversation. Nothing else. */
|
|
40
48
|
const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/];
|
|
@@ -87,8 +95,16 @@ export function boardPortFrom(env = process.env) {
|
|
|
87
95
|
|
|
88
96
|
|
|
89
97
|
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
90
102
|
|
|
91
103
|
|
|
104
|
+
/** Three missed heartbeats. */
|
|
105
|
+
const DEFAULT_STREAM_IDLE_SECONDS = 45;
|
|
106
|
+
const DEFAULT_STREAM_RETRY_SECONDS = 30;
|
|
107
|
+
|
|
92
108
|
|
|
93
109
|
|
|
94
110
|
/** One subscriber and how far through the change log it has been told. */
|
|
@@ -102,10 +118,17 @@ export function boardPortFrom(env = process.env) {
|
|
|
102
118
|
|
|
103
119
|
|
|
104
120
|
/** Changes as a phone gets them: one per task, the latest, or a reset when there are too many. */
|
|
105
|
-
|
|
121
|
+
/** The latest change of each task, in the order they last changed. */
|
|
122
|
+
function latestOf(items ) {
|
|
106
123
|
const latest = new Map ();
|
|
107
124
|
for (const item of items) { latest.delete(item.recId); latest.set(item.recId, item); }
|
|
108
|
-
return
|
|
125
|
+
return [...latest.values()];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function changedEvent(items , carried = new Map ()) {
|
|
129
|
+
const latest = latestOf(items);
|
|
130
|
+
if (latest.length > CHANGED_ITEMS_MAX) return { t: "board/changed", reset: true };
|
|
131
|
+
return { t: "board/changed", items: latest.map((item) => ({ ...item, ...carried.get(item.recId) })) };
|
|
109
132
|
}
|
|
110
133
|
|
|
111
134
|
function changeOf(value ) {
|
|
@@ -127,11 +150,19 @@ export class BoardReader {
|
|
|
127
150
|
round = 0;
|
|
128
151
|
/** The backend's push stream: open, unknown (try it), or not there (an older backend answers 404). */
|
|
129
152
|
stream = null;
|
|
153
|
+
/** Changes go out one batch at a time and in order, each after its tasks were read. */
|
|
154
|
+
delivery = Promise.resolve();
|
|
130
155
|
streamSupported = null;
|
|
156
|
+
/** Not before this time (ms) after the backend refused a stream for a passing reason. */
|
|
157
|
+
streamRetryAt = 0;
|
|
158
|
+
streamIdleMs ;
|
|
159
|
+
streamRetryMs ;
|
|
131
160
|
|
|
132
161
|
constructor(options = {}) {
|
|
133
162
|
this.port = options.port ?? boardPortFrom();
|
|
134
163
|
this.pollMs = (options.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000;
|
|
164
|
+
this.streamIdleMs = (options.streamIdleSeconds ?? DEFAULT_STREAM_IDLE_SECONDS) * 1000;
|
|
165
|
+
this.streamRetryMs = (options.streamRetrySeconds ?? DEFAULT_STREAM_RETRY_SECONDS) * 1000;
|
|
135
166
|
}
|
|
136
167
|
|
|
137
168
|
/** How many phones are subscribed. */
|
|
@@ -197,9 +228,11 @@ export class BoardReader {
|
|
|
197
228
|
this.stream?.abort();
|
|
198
229
|
this.stream = null;
|
|
199
230
|
this.streamSupported = null;
|
|
231
|
+
this.streamRetryAt = 0;
|
|
200
232
|
this.end = null;
|
|
201
233
|
this.up = null;
|
|
202
234
|
this.polling = false;
|
|
235
|
+
this.delivery = Promise.resolve();
|
|
203
236
|
this.round++;
|
|
204
237
|
}
|
|
205
238
|
|
|
@@ -237,24 +270,70 @@ export class BoardReader {
|
|
|
237
270
|
cursor = next;
|
|
238
271
|
}
|
|
239
272
|
} catch {
|
|
240
|
-
sub.admitting = false;
|
|
273
|
+
sub.admitting = false;
|
|
274
|
+
// Try again shortly. Polling would admit it on its next read, but while the backend pushes nothing polls.
|
|
275
|
+
const retry = setTimeout(() => { if (round === this.round && this.subs.has(sub)) void this.admit(sub); }, this.pollMs);
|
|
276
|
+
retry.unref();
|
|
241
277
|
return;
|
|
242
278
|
}
|
|
243
279
|
if (round !== this.round || !this.subs.has(sub)) return;
|
|
280
|
+
// What changed comes with the tasks as they are now; reading them takes a moment, in which more may change, so
|
|
281
|
+
// read the log again up to where the shared reader got before this subscriber starts hearing from it.
|
|
282
|
+
let carried = new Map ();
|
|
283
|
+
if (!reset && missed.length) {
|
|
284
|
+
carried = await this.carry(missed);
|
|
285
|
+
try {
|
|
286
|
+
for (let page = 0; cursor !== null && cursor < this.end && page < CATCH_UP_PAGES; page++) {
|
|
287
|
+
const { items, next } = await this.changes(cursor);
|
|
288
|
+
missed.push(...items);
|
|
289
|
+
if (next === cursor && items.length === 0) break;
|
|
290
|
+
cursor = next;
|
|
291
|
+
}
|
|
292
|
+
} catch { /* the late ones go without contents; the phone reads them */ }
|
|
293
|
+
if (round !== this.round || !this.subs.has(sub)) return;
|
|
294
|
+
}
|
|
244
295
|
sub.admitting = false;
|
|
245
296
|
sub.cursor = Math.max(cursor ?? this.end , this.end );
|
|
246
297
|
sub.listener(this.state(true, sub.cursor));
|
|
247
298
|
if (reset) sub.listener({ t: "board/changed", reset: true });
|
|
248
|
-
else if (missed.length) sub.listener(changedEvent(missed));
|
|
299
|
+
else if (missed.length) sub.listener(changedEvent(missed, carried));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Reads each changed task (its latest change only) from the backend, so the phone needs no read of its own. Only while
|
|
304
|
+
* a phone is subscribed, which is while its 任务 page is on screen, so marking them seen is what the phone's own read
|
|
305
|
+
* did before. Too many at once, and the phone reads them (or the whole board) itself.
|
|
306
|
+
*/
|
|
307
|
+
async carry(items ) {
|
|
308
|
+
const carried = new Map ();
|
|
309
|
+
const latest = latestOf(items);
|
|
310
|
+
if (latest.length > CARRIED_TASKS_MAX) return carried;
|
|
311
|
+
await Promise.all(latest.map(async ({ recId }) => {
|
|
312
|
+
const answer = await this.answer(`/v1/recommendations/${encodeURIComponent(recId)}`);
|
|
313
|
+
if ("error" in answer) return;
|
|
314
|
+
if (answer.status === 404) { carried.set(recId, { gone: true }); return; }
|
|
315
|
+
if (answer.status !== 200 || Buffer.byteLength(answer.body, "utf8") > CARRIED_TASK_MAX_BYTES) return;
|
|
316
|
+
try { carried.set(recId, { task: JSON.parse(answer.body) }); } catch { /* the phone reads it */ }
|
|
317
|
+
}));
|
|
318
|
+
return carried;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Reads the changed tasks, then tells each subscriber; batches stay in order. */
|
|
322
|
+
send(items , end ) {
|
|
323
|
+
const round = this.round;
|
|
324
|
+
this.delivery = this.delivery.then(async () => {
|
|
325
|
+
const carried = await this.carry(items);
|
|
326
|
+
if (round === this.round) this.deliver(items, end, carried);
|
|
327
|
+
});
|
|
249
328
|
}
|
|
250
329
|
|
|
251
330
|
/** New changes up to `end`: each subscriber gets those past its own place. */
|
|
252
|
-
deliver(items , end ) {
|
|
331
|
+
deliver(items , end , carried = new Map ()) {
|
|
253
332
|
for (const sub of this.subs) {
|
|
254
333
|
if (sub.cursor === null) continue;
|
|
255
334
|
const fresh = items.filter((item) => item.seq > sub.cursor );
|
|
256
335
|
sub.cursor = Math.max(sub.cursor, end);
|
|
257
|
-
if (fresh.length) sub.listener(changedEvent(fresh));
|
|
336
|
+
if (fresh.length) sub.listener(changedEvent(fresh, carried));
|
|
258
337
|
}
|
|
259
338
|
}
|
|
260
339
|
|
|
@@ -294,9 +373,9 @@ export class BoardReader {
|
|
|
294
373
|
if (up) this.end = end;
|
|
295
374
|
this.setUp(up);
|
|
296
375
|
if (!up) return;
|
|
297
|
-
if (items.length) this.
|
|
376
|
+
if (items.length) this.send(items, end );
|
|
298
377
|
for (const sub of this.subs) if (sub.cursor === null) void this.admit(sub);
|
|
299
|
-
if (this.streamSupported !== false) void this.openStream(round);
|
|
378
|
+
if (this.streamSupported !== false && Date.now() >= this.streamRetryAt) void this.openStream(round);
|
|
300
379
|
}
|
|
301
380
|
|
|
302
381
|
/**
|
|
@@ -307,21 +386,36 @@ export class BoardReader {
|
|
|
307
386
|
if (this.stream || this.end === null) return;
|
|
308
387
|
const controller = new AbortController();
|
|
309
388
|
this.stream = controller;
|
|
389
|
+
// A backend that takes the connection and then says nothing must not hold the bridge: while a stream is open
|
|
390
|
+
// nothing polls, so silence would also hide the backend going away. Its heartbeat keeps this from firing.
|
|
391
|
+
let idle = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
|
|
392
|
+
idle.unref();
|
|
393
|
+
const heard = () => {
|
|
394
|
+
clearTimeout(idle);
|
|
395
|
+
idle = setTimeout(() => controller.abort(), this.streamIdleMs);
|
|
396
|
+
idle.unref();
|
|
397
|
+
};
|
|
310
398
|
let response ;
|
|
311
399
|
try {
|
|
312
400
|
response = await this.fetch(`/v1/changes/stream?after=${this.end}`, undefined, controller.signal);
|
|
313
401
|
} catch {
|
|
402
|
+
clearTimeout(idle);
|
|
314
403
|
if (this.stream === controller) this.stream = null;
|
|
315
404
|
return;
|
|
316
405
|
}
|
|
317
|
-
if (round !== this.round || this.stream !== controller) { controller.abort(); return; }
|
|
318
|
-
|
|
319
|
-
|
|
406
|
+
if (round !== this.round || this.stream !== controller) { clearTimeout(idle); controller.abort(); return; }
|
|
407
|
+
const eventStream = (response.headers.get("content-type") ?? "").startsWith("text/event-stream");
|
|
408
|
+
if (!response.ok || !eventStream || !response.body) {
|
|
409
|
+
clearTimeout(idle);
|
|
410
|
+
// Only an older backend lacks it (404, or a 200 that is not an event stream); busy (429) or starting (5xx) passes.
|
|
411
|
+
if (response.status === 404 || (response.ok && !eventStream)) this.streamSupported = false;
|
|
412
|
+
else this.streamRetryAt = Date.now() + this.streamRetryMs;
|
|
320
413
|
this.stream = null;
|
|
321
414
|
controller.abort();
|
|
322
415
|
return;
|
|
323
416
|
}
|
|
324
417
|
this.streamSupported = true;
|
|
418
|
+
heard();
|
|
325
419
|
let buffer = "", pending = [], flush = null;
|
|
326
420
|
const send = () => {
|
|
327
421
|
flush = null;
|
|
@@ -330,11 +424,12 @@ export class BoardReader {
|
|
|
330
424
|
pending = [];
|
|
331
425
|
const end = Math.max(this.end ?? 0, ...batch.map((item) => item.seq));
|
|
332
426
|
this.end = end;
|
|
333
|
-
this.
|
|
427
|
+
this.send(batch, end);
|
|
334
428
|
};
|
|
335
429
|
try {
|
|
336
430
|
const decoder = new TextDecoder();
|
|
337
431
|
for await (const chunk of response.body ) {
|
|
432
|
+
heard();
|
|
338
433
|
buffer += decoder.decode(chunk, { stream: true });
|
|
339
434
|
let split ;
|
|
340
435
|
while ((split = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
@@ -351,6 +446,7 @@ export class BoardReader {
|
|
|
351
446
|
}
|
|
352
447
|
}
|
|
353
448
|
} catch { /* aborted, or the backend went away */ }
|
|
449
|
+
clearTimeout(idle);
|
|
354
450
|
if (flush) { clearTimeout(flush); send(); }
|
|
355
451
|
if (this.stream === controller) this.stream = null;
|
|
356
452
|
}
|
package/src/protocol.js
CHANGED
|
@@ -249,13 +249,15 @@ export class FrameCipher {
|
|
|
249
249
|
|
|
250
250
|
/** One task that changed, from agens-backend's change log (0.3.14). */
|
|
251
251
|
|
|
252
|
+
/** As a phone gets it from 0.3.17 on: the task as the backend now has it (`GET /v1/recommendations/:id`), or gone. */
|
|
253
|
+
|
|
252
254
|
/**
|
|
253
255
|
* What a subscriber hears. From 0.3.14 a board/state that says up also gives `seq` (where in the change log the phone
|
|
254
256
|
* starts), `caps` and the bridge's version, and board/changed names the tasks, or says `reset` when there are too many.
|
|
255
257
|
*/
|
|
256
258
|
|
|
257
259
|
|
|
258
|
-
|
|
260
|
+
|
|
259
261
|
|
|
260
262
|
|
|
261
263
|
/** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */
|