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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.14",
3
+ "version": "0.3.17",
4
4
  "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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. Not /assist, /mcp, /search, /changes (the bridge's own poll) or anything under /ui.
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
- function changedEvent(items ) {
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 latest.size > CHANGED_ITEMS_MAX ? { t: "board/changed", reset: true } : { t: "board/changed", items: [...latest.values()] };
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; // the next read of the log tries again
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.deliver(items, end );
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
- if (!response.ok || !(response.headers.get("content-type") ?? "").startsWith("text/event-stream") || !response.body) {
319
- this.streamSupported = false;
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.deliver(batch, end);
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. */