@mirrowel/opencode-openshare 0.1.1 → 0.2.0-dev.2
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 +36 -37
- package/ci/README.md +9 -3
- package/ci/openshare.js +916 -199
- package/dist/binding-queue.d.ts +1 -0
- package/dist/binding-queue.js +22 -0
- package/dist/commands.js +41 -285
- package/dist/contract.d.ts +156 -0
- package/dist/contract.js +263 -0
- package/dist/core.js +888 -96
- package/dist/dialogs.js +39 -1
- package/dist/settings.js +141 -16
- package/dist/share-flow.d.ts +7 -2
- package/dist/share-flow.js +32 -17
- package/dist/sidebar.d.ts +3 -1
- package/dist/sidebar.js +3 -38
- package/dist/tui.js +286 -421
- package/dist/v2-server.d.ts +0 -27
- package/dist/v2-server.js +61 -121
- package/package.json +3 -1
- package/src/binding-queue.ts +26 -0
- package/src/commands.ts +41 -280
- package/src/contract.ts +320 -0
- package/src/core.ts +864 -99
- package/src/dialogs.tsx +45 -2
- package/src/settings.ts +133 -15
- package/src/share-flow.ts +33 -19
- package/src/sidebar.tsx +4 -45
- package/src/v2-server.ts +57 -110
package/dist/core.js
CHANGED
|
@@ -42,8 +42,11 @@
|
|
|
42
42
|
// - The plugin strips this key from everything it syncs — the write
|
|
43
43
|
// secret must never reach viewers.
|
|
44
44
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { createHash } from "node:crypto";
|
|
45
46
|
import { join } from "node:path";
|
|
46
47
|
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
|
|
48
|
+
import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
|
|
49
|
+
import { queueBindingWrite } from "./binding-queue.js";
|
|
47
50
|
const DEFAULT_POLICY = {
|
|
48
51
|
mode: "persistent",
|
|
49
52
|
turnGraceMs: 120000,
|
|
@@ -93,11 +96,12 @@ function itemFor(type, data, sessionID) {
|
|
|
93
96
|
if (type === "prefs")
|
|
94
97
|
return { key: "prefs", item: { type, data } };
|
|
95
98
|
if (type === "session")
|
|
96
|
-
return { key: `session/${data?.id ?? ""}`, item: { type, data } };
|
|
99
|
+
return { key: `session/${data?.id ?? ""}`, item: { type, sessionID, data } };
|
|
100
|
+
// v2 messages carry no sessionID inside data (v1 infos did); the item
|
|
101
|
+
// envelope stamps it so multi-session folds (parent + subagents) can
|
|
102
|
+
// group without guessing
|
|
97
103
|
if (type === "message")
|
|
98
|
-
return { key: `message/${data?.id ?? ""}`, item: { type, data } };
|
|
99
|
-
if (type === "part")
|
|
100
|
-
return { key: `part/${data?.messageID ?? ""}/${data?.id ?? ""}`, item: { type, data } };
|
|
104
|
+
return { key: `message/${data?.id ?? ""}`, item: { type, sessionID, data } };
|
|
101
105
|
if (type === "session_diff")
|
|
102
106
|
return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } };
|
|
103
107
|
return undefined;
|
|
@@ -269,6 +273,8 @@ export function createEngine(deps) {
|
|
|
269
273
|
initTimer: null,
|
|
270
274
|
initWasAttach: false,
|
|
271
275
|
backoff: 1000,
|
|
276
|
+
backoffResetTimer: null,
|
|
277
|
+
spoolSaveTimer: null,
|
|
272
278
|
reconnectTimer: null,
|
|
273
279
|
intentionalClose: false,
|
|
274
280
|
connectQueued: false,
|
|
@@ -334,12 +340,19 @@ export function createEngine(deps) {
|
|
|
334
340
|
if (b && typeof b === "object" && b.policy) {
|
|
335
341
|
applyPolicy(b.policy, url);
|
|
336
342
|
}
|
|
343
|
+
// frame auth capability: the server accepts the token as the
|
|
344
|
+
// first WS frame — lets us keep it off the query string (proxy
|
|
345
|
+
// and access logs otherwise capture the full credential)
|
|
346
|
+
if (b && typeof b === "object" && b.frameAuth) {
|
|
347
|
+
frameAuthUrls.add(url);
|
|
348
|
+
}
|
|
337
349
|
})
|
|
338
350
|
.catch(() => {
|
|
339
351
|
// unreachable worker: defaults stay; the init ack will correct us
|
|
340
352
|
policyAsked.delete(url);
|
|
341
353
|
});
|
|
342
354
|
};
|
|
355
|
+
const frameAuthUrls = new Set();
|
|
343
356
|
const wsUrl = (url) => url.replace(/^http/, "ws");
|
|
344
357
|
// ---- spool root ---------------------------------------------------------
|
|
345
358
|
let spoolRoot = spoolDirFor(cfg);
|
|
@@ -380,6 +393,8 @@ export function createEngine(deps) {
|
|
|
380
393
|
activated: false,
|
|
381
394
|
wroteBinding: false,
|
|
382
395
|
everConnected: false,
|
|
396
|
+
merged: new Map(),
|
|
397
|
+
sentHash: new Map(),
|
|
383
398
|
});
|
|
384
399
|
const refreshConfig = () => {
|
|
385
400
|
// settings may change between activations (the TUI writes the config
|
|
@@ -419,6 +434,9 @@ export function createEngine(deps) {
|
|
|
419
434
|
}
|
|
420
435
|
st.activated = true;
|
|
421
436
|
st.opts = opts;
|
|
437
|
+
// re-activation re-backfills the whole session (unshare -> share
|
|
438
|
+
// again): the enqueue dedupe must not skip the returning content
|
|
439
|
+
st.sentHash.clear();
|
|
422
440
|
const gated = mode === "manual" || opts2?.gated === true;
|
|
423
441
|
if (gated) {
|
|
424
442
|
// seed the share status from the session's CURRENT state before
|
|
@@ -493,25 +511,30 @@ export function createEngine(deps) {
|
|
|
493
511
|
async function writeMetadata(st, value) {
|
|
494
512
|
if (!st.client)
|
|
495
513
|
return;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
metadata
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
514
|
+
// the read-modify-write runs inside the shared per-session queue: the
|
|
515
|
+
// TUI patches the same metadata (opt-outs, collisions) and interleaved
|
|
516
|
+
// RMWs silently lose one side's update (see binding-queue.ts)
|
|
517
|
+
await queueBindingWrite(st.sid, async () => {
|
|
518
|
+
try {
|
|
519
|
+
const info = unwrap(await st.client.session.get({
|
|
520
|
+
path: { id: st.sid },
|
|
521
|
+
...(deps.directory ? { query: { directory: deps.directory } } : {}),
|
|
522
|
+
}));
|
|
523
|
+
const metadata = { ...(info?.metadata ?? {}) };
|
|
524
|
+
if (value === null)
|
|
525
|
+
delete metadata[METADATA_KEY];
|
|
526
|
+
else
|
|
527
|
+
metadata[METADATA_KEY] = value;
|
|
528
|
+
unwrap(await st.client.session.update({
|
|
529
|
+
path: { id: st.sid },
|
|
530
|
+
body: { metadata },
|
|
531
|
+
...(deps.directory ? { query: { directory: deps.directory } } : {}),
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
log("metadata write failed (spool still covers reconnects):", e?.message || e);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
515
538
|
}
|
|
516
539
|
/** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
|
|
517
540
|
* rev,url,enabled?}}}) or legacy flat ({id,secret,rev,url}) which maps
|
|
@@ -632,13 +655,55 @@ export function createEngine(deps) {
|
|
|
632
655
|
const spoolSave = (st, r) => {
|
|
633
656
|
try {
|
|
634
657
|
writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }));
|
|
635
|
-
|
|
658
|
+
// the queue line IS the wire frame when prebuilt (single
|
|
659
|
+
// serialization end-to-end); a pending frame without wire (legacy
|
|
660
|
+
// spool load) serializes here
|
|
661
|
+
writeFileSync(queuePath(st, r.url), r.pending.map((f) => f.wire ?? JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""));
|
|
662
|
+
// the migrated content now lives in the new queue (or was acked):
|
|
663
|
+
// retire the single-server-era files so they don't accumulate forever
|
|
664
|
+
rmSync(legacyMetaPath(st), { force: true });
|
|
665
|
+
rmSync(legacyQueuePath(st), { force: true });
|
|
636
666
|
}
|
|
637
667
|
catch (e) {
|
|
638
668
|
logError("spool save failed", e?.message || e);
|
|
639
669
|
}
|
|
640
670
|
};
|
|
671
|
+
// hot path: appending one pending frame must not re-serialize the whole
|
|
672
|
+
// queue (O(n^2) during backfill bursts) — append the line instead
|
|
673
|
+
const spoolAppend = (st, r, f) => {
|
|
674
|
+
try {
|
|
675
|
+
appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n");
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
// the frame lives ONLY in memory now — flag the remote so the next
|
|
679
|
+
// spoolLoad does NOT replace in-memory pending with the shorter
|
|
680
|
+
// disk queue (silent loss otherwise; live-caught class in review)
|
|
681
|
+
r.spoolAppendFailed = true;
|
|
682
|
+
logError("spool append failed", e?.message || e);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
/** Debounced ack-path spool rewrite: one ack per chunk during a
|
|
686
|
+
* multi-chunk replay each rewrote the WHOLE queue file (O(n^2) —
|
|
687
|
+
* ~160MB of sync disk churn on a 25-chunk outage recovery). A crash
|
|
688
|
+
* inside the 300ms window only loses "these frames were acked" — the
|
|
689
|
+
* next connect replays idempotent keyed upserts, which the protocol
|
|
690
|
+
* already tolerates. init-ok keeps the immediate save (binding/
|
|
691
|
+
* recovery info). */
|
|
692
|
+
const scheduleSpoolSave = (st, r) => {
|
|
693
|
+
if (r.spoolSaveTimer)
|
|
694
|
+
return;
|
|
695
|
+
r.spoolSaveTimer = setTimeout(() => {
|
|
696
|
+
r.spoolSaveTimer = null;
|
|
697
|
+
if (!st.closed)
|
|
698
|
+
spoolSave(st, r);
|
|
699
|
+
}, 300);
|
|
700
|
+
};
|
|
641
701
|
const spoolLoad = (st, r) => {
|
|
702
|
+
// meta first (share binding recovery), in its OWN try: a missing meta
|
|
703
|
+
// file is normal (it's only written after the first successful
|
|
704
|
+
// handshake) and must NOT touch pending (live-caught class: the old
|
|
705
|
+
// blanket catch wiped in-memory pending AND skipped the queue read,
|
|
706
|
+
// so everything queued before the first successful connect was lost).
|
|
642
707
|
try {
|
|
643
708
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
|
|
644
709
|
// metadata binding (db) is authoritative when both exist and disagree
|
|
@@ -647,16 +712,48 @@ export function createEngine(deps) {
|
|
|
647
712
|
r.secret = meta.secret;
|
|
648
713
|
r.ackedRev = meta.ackedRev ?? 0;
|
|
649
714
|
}
|
|
715
|
+
}
|
|
716
|
+
catch { }
|
|
717
|
+
try {
|
|
650
718
|
if (existsSync(queuePath(st, r.url))) {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
719
|
+
// append-only spool: a crash mid-append can tear the LAST line —
|
|
720
|
+
// keep the valid prefix instead of dropping the whole queue.
|
|
721
|
+
// The disk queue is the authority: every in-memory frame was
|
|
722
|
+
// appended before it was tracked, so replacing pending with the
|
|
723
|
+
// parsed queue loses nothing. A FAILED read keeps in-memory
|
|
724
|
+
// pending untouched.
|
|
725
|
+
const disk = [];
|
|
726
|
+
for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
|
|
727
|
+
if (!line.trim())
|
|
728
|
+
continue;
|
|
729
|
+
try {
|
|
730
|
+
disk.push(JSON.parse(line));
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`);
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// seq hygiene: re-stamp pre-seq spool lines (an ack without seq
|
|
738
|
+
// would otherwise clear ALL pending — the H2 wipe, once per
|
|
739
|
+
// upgrade) and seed the counter past every restored seq (a
|
|
740
|
+
// restart resetting to 0 would collide new frames with old ones)
|
|
741
|
+
for (const f of disk) {
|
|
742
|
+
if (typeof f.seq !== "number")
|
|
743
|
+
f.seq = ++nextSeq;
|
|
744
|
+
else if (f.seq >= nextSeq)
|
|
745
|
+
nextSeq = f.seq + 1;
|
|
746
|
+
}
|
|
747
|
+
// a failed append means the disk queue can be SHORTER than
|
|
748
|
+
// memory — the disk is only authoritative when every append
|
|
749
|
+
// landed (otherwise the replace would drop the memory-only frame)
|
|
750
|
+
if (!r.spoolAppendFailed)
|
|
751
|
+
r.pending = disk;
|
|
752
|
+
else
|
|
753
|
+
r.spoolAppendFailed = false;
|
|
655
754
|
}
|
|
656
755
|
}
|
|
657
|
-
catch {
|
|
658
|
-
r.pending = [];
|
|
659
|
-
}
|
|
756
|
+
catch { }
|
|
660
757
|
// single-server-era spool (no url suffix): belongs to the first target
|
|
661
758
|
if (!r.shareId && targets[0]?.url === r.url) {
|
|
662
759
|
try {
|
|
@@ -679,12 +776,26 @@ export function createEngine(deps) {
|
|
|
679
776
|
};
|
|
680
777
|
const spoolClear = (st, r) => {
|
|
681
778
|
try {
|
|
779
|
+
if (r.spoolSaveTimer) {
|
|
780
|
+
clearTimeout(r.spoolSaveTimer);
|
|
781
|
+
r.spoolSaveTimer = null;
|
|
782
|
+
}
|
|
682
783
|
rmSync(metaPath(st, r.url), { force: true });
|
|
683
784
|
rmSync(queuePath(st, r.url), { force: true });
|
|
785
|
+
// single-server-era files die with the session too
|
|
786
|
+
rmSync(legacyMetaPath(st), { force: true });
|
|
787
|
+
rmSync(legacyQueuePath(st), { force: true });
|
|
684
788
|
}
|
|
685
789
|
catch { }
|
|
686
790
|
};
|
|
687
791
|
// ---- websocket transport (per remote) ----------------------------------
|
|
792
|
+
/** Monotonic client frame sequence: every chunk-frame gets one; the
|
|
793
|
+
* server echoes it in the ack, and the plugin clears exactly that
|
|
794
|
+
* chunk from pending. (Live-caught class: one logical frame split into
|
|
795
|
+
* N chunks — the worker acks EACH chunk, and a blanket "pending = []"
|
|
796
|
+
* on the first ack erased chunks 2..N; a socket death right there lost
|
|
797
|
+
* them permanently.) */
|
|
798
|
+
let nextSeq = 0;
|
|
688
799
|
const wsSend = (r, obj) => {
|
|
689
800
|
if (debugEvents)
|
|
690
801
|
log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${obj?.op} (ready=${r.wsReady})`);
|
|
@@ -696,16 +807,70 @@ export function createEngine(deps) {
|
|
|
696
807
|
}
|
|
697
808
|
return true;
|
|
698
809
|
};
|
|
699
|
-
/** Sync frames are chunked
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
|
|
810
|
+
/** Sync frames are chunked by BOTH item count and serialized bytes.
|
|
811
|
+
* Count-only chunking could pack 200 large tool outputs into a frame
|
|
812
|
+
* that exceeds the server's WS receive cap (CF 32 MiB, Bun 16 MB
|
|
813
|
+
* default) and die wholesale. Budget keeps every frame cheap to apply
|
|
814
|
+
* and rebroadcast; worker apply is keyed-idempotent, so chunks just
|
|
815
|
+
* bump rev. An item larger than the whole budget rides alone in its
|
|
816
|
+
* own frame (the worker fits oversize items to its storage cap).
|
|
817
|
+
* Status rides each chunk; last write wins.
|
|
818
|
+
* Returns each chunk's PREBUILT items JSON: the wire frame and the
|
|
819
|
+
* spool line are derived from it by concatenation — every item used
|
|
820
|
+
* to be serialized 3-5x per remote per flush (chunk measure, spool
|
|
821
|
+
* append, ws send, sendSync re-chunk). */
|
|
822
|
+
const chunkedFrames = (items, maxItems, maxBytes) => {
|
|
823
|
+
const chunks = [];
|
|
824
|
+
let cur = [];
|
|
825
|
+
let curJson = [];
|
|
826
|
+
let curBytes = 0;
|
|
827
|
+
for (const it of items) {
|
|
828
|
+
const j = JSON.stringify(it) ?? "null";
|
|
829
|
+
if (cur.length && (cur.length >= maxItems || curBytes + j.length > maxBytes)) {
|
|
830
|
+
chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
|
|
831
|
+
cur = [];
|
|
832
|
+
curJson = [];
|
|
833
|
+
curBytes = 0;
|
|
834
|
+
}
|
|
835
|
+
cur.push(it);
|
|
836
|
+
curJson.push(j);
|
|
837
|
+
curBytes += j.length;
|
|
838
|
+
}
|
|
839
|
+
if (cur.length)
|
|
840
|
+
chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
|
|
841
|
+
return chunks;
|
|
842
|
+
};
|
|
843
|
+
/** legacy chunker (count/byte split without prebuilt JSON) — replay
|
|
844
|
+
* paths that only need the split */
|
|
845
|
+
const chunkItems = (items, maxItems, maxBytes) => chunkedFrames(items, maxItems, maxBytes).map((c) => c.items);
|
|
846
|
+
/** the wire sync frame, built by concatenation over the prebuilt items
|
|
847
|
+
* JSON — byte-identical to JSON.stringify({op,items,status,seq}) */
|
|
848
|
+
const syncFrameStr = (itemsJson, status, seq) => `{"op":"sync","items":${itemsJson},"status":${JSON.stringify(status)}${seq !== undefined ? `,"seq":${seq}` : ""}}`;
|
|
849
|
+
const wsSendRaw = (r, s) => {
|
|
850
|
+
if (debugEvents)
|
|
851
|
+
log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send raw (${Math.round(s.length / 1024)}KB, ready=${r.wsReady})`);
|
|
852
|
+
try {
|
|
853
|
+
r.ws?.send(s);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
return true;
|
|
859
|
+
};
|
|
703
860
|
const sendSync = (r, f) => {
|
|
704
861
|
if (!f.items.length)
|
|
705
|
-
return wsSend(r, { op: "sync", items: [], status: f.status });
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
862
|
+
return wsSend(r, { op: "sync", items: [], status: f.status, seq: f.seq });
|
|
863
|
+
// prebuilt wire (the hot flush path): one serialization total
|
|
864
|
+
if (f.wire)
|
|
865
|
+
return wsSendRaw(r, f.wire);
|
|
866
|
+
// replayed/legacy frames: chunk-shaped by construction — only an
|
|
867
|
+
// oversize one (a pre-chunking spool line) needs re-splitting
|
|
868
|
+
const itemsJson = JSON.stringify(f.items);
|
|
869
|
+
if (f.items.length <= 200 && itemsJson.length <= 512 * 1024) {
|
|
870
|
+
return wsSendRaw(r, syncFrameStr(itemsJson, f.status, f.seq));
|
|
871
|
+
}
|
|
872
|
+
for (const c of chunkedFrames(f.items, 200, 512 * 1024))
|
|
873
|
+
wsSendRaw(r, syncFrameStr(c.json, f.status, f.seq));
|
|
709
874
|
return true;
|
|
710
875
|
};
|
|
711
876
|
const announceUrl = (st, r) => {
|
|
@@ -899,7 +1064,12 @@ export function createEngine(deps) {
|
|
|
899
1064
|
logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`);
|
|
900
1065
|
return;
|
|
901
1066
|
}
|
|
902
|
-
|
|
1067
|
+
// frame auth: when the server advertises it, keep the token off the
|
|
1068
|
+
// URL (query strings land in proxy/access logs). The auth frame
|
|
1069
|
+
// rides the socket in order — the server sets its identity before
|
|
1070
|
+
// our init arrives. Old servers fall back to ?token=.
|
|
1071
|
+
const useFrameAuth = !!token && frameAuthUrls.has(r.url);
|
|
1072
|
+
const ws = new WebSocket(`${wsUrl(r.url)}/api/ws${useFrameAuth ? "" : `?token=${encodeURIComponent(token ?? "")}`}`);
|
|
903
1073
|
r.ws = ws;
|
|
904
1074
|
await new Promise((resolve, reject) => {
|
|
905
1075
|
const t = setTimeout(() => reject(new Error("ws open timeout")), 15000);
|
|
@@ -940,6 +1110,32 @@ export function createEngine(deps) {
|
|
|
940
1110
|
if (!st.closed)
|
|
941
1111
|
scheduleReconnect(st, r);
|
|
942
1112
|
});
|
|
1113
|
+
if (useFrameAuth) {
|
|
1114
|
+
wsSend(r, { op: "auth", token });
|
|
1115
|
+
// the auth and init frames can interleave in the worker's
|
|
1116
|
+
// concurrent webSocketMessage invocations — wait for the authed
|
|
1117
|
+
// ack before authenticating anything (timeout -> fall back to a
|
|
1118
|
+
// query-token reconnect)
|
|
1119
|
+
const authed = await new Promise((resolve) => {
|
|
1120
|
+
const t = setTimeout(() => resolve(false), 3000);
|
|
1121
|
+
r.authWaiter = () => {
|
|
1122
|
+
clearTimeout(t);
|
|
1123
|
+
resolve(true);
|
|
1124
|
+
};
|
|
1125
|
+
});
|
|
1126
|
+
r.authWaiter = undefined;
|
|
1127
|
+
if (!authed) {
|
|
1128
|
+
logError(`ws ${r.url.slice(0, 24)}: frame-auth ack timeout - reconnecting with query token`);
|
|
1129
|
+
frameAuthUrls.delete(r.url);
|
|
1130
|
+
try {
|
|
1131
|
+
ws.close(4000, "auth timeout");
|
|
1132
|
+
}
|
|
1133
|
+
catch { }
|
|
1134
|
+
if (!st.closed)
|
|
1135
|
+
scheduleReconnect(st, r);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
943
1139
|
if (r.initWasAttach) {
|
|
944
1140
|
wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
|
|
945
1141
|
}
|
|
@@ -973,7 +1169,9 @@ export function createEngine(deps) {
|
|
|
973
1169
|
const scheduleReconnect = (st, r) => {
|
|
974
1170
|
if (st.closed || r.reconnectTimer)
|
|
975
1171
|
return;
|
|
976
|
-
|
|
1172
|
+
// jitter (±20%) so fan-out remotes and multiple sessions never sync
|
|
1173
|
+
// their retry storms onto the same second
|
|
1174
|
+
const wait = Math.max(250, Math.round(r.backoff * (0.8 + Math.random() * 0.4)));
|
|
977
1175
|
r.backoff = Math.min(r.backoff * 2, 60000);
|
|
978
1176
|
r.reconnectTimer = setTimeout(() => {
|
|
979
1177
|
r.reconnectTimer = null;
|
|
@@ -1065,6 +1263,15 @@ export function createEngine(deps) {
|
|
|
1065
1263
|
}
|
|
1066
1264
|
};
|
|
1067
1265
|
const handleFrame = async (st, r, frame) => {
|
|
1266
|
+
if (frame.op === "authed") {
|
|
1267
|
+
// frame-auth handshake ack: release the init (the auth and init
|
|
1268
|
+
// frames can interleave server-side — webSocketMessage invocations
|
|
1269
|
+
// run concurrently — so the init must WAIT for this, else it
|
|
1270
|
+
// authenticates as anonymous/tokenless)
|
|
1271
|
+
r.authWaiter?.();
|
|
1272
|
+
r.authWaiter = undefined;
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1068
1275
|
if (frame.op === "init") {
|
|
1069
1276
|
if (!frame.ok) {
|
|
1070
1277
|
const err = String(frame.error ?? "");
|
|
@@ -1123,6 +1330,21 @@ export function createEngine(deps) {
|
|
|
1123
1330
|
// bad base (mismatch protocol). Content REPLAYS to the new share —
|
|
1124
1331
|
// pending is never dropped on a reset (live-caught: the recreate
|
|
1125
1332
|
// husk had the title but zero messages).
|
|
1333
|
+
if (/limit|too many|rate/i.test(err)) {
|
|
1334
|
+
// quota/429 ("max shares reached", "daily create limit", the
|
|
1335
|
+
// anonymous caps): an immediate re-init on the same socket is a
|
|
1336
|
+
// hot loop against the server — back off through the normal
|
|
1337
|
+
// reconnect cadence instead
|
|
1338
|
+
logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
|
|
1339
|
+
r.connecting = false;
|
|
1340
|
+
try {
|
|
1341
|
+
r.ws?.close(4000, "rate limited");
|
|
1342
|
+
}
|
|
1343
|
+
catch { }
|
|
1344
|
+
if (!st.closed)
|
|
1345
|
+
scheduleReconnect(st, r);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1126
1348
|
logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
|
|
1127
1349
|
deleteAbandoned(r);
|
|
1128
1350
|
r.shareId = null;
|
|
@@ -1154,11 +1376,26 @@ export function createEngine(deps) {
|
|
|
1154
1376
|
r.connecting = false;
|
|
1155
1377
|
r.initWasAttach = false;
|
|
1156
1378
|
r.collided = null;
|
|
1379
|
+
// a successful handshake proves write access: an admin unfreeze
|
|
1380
|
+
// revives the remote from here (frozenRemote is otherwise sticky)
|
|
1381
|
+
if (r.frozenRemote && !frame.error)
|
|
1382
|
+
r.frozenRemote = false;
|
|
1157
1383
|
if (r.initTimer) {
|
|
1158
1384
|
clearTimeout(r.initTimer);
|
|
1159
1385
|
r.initTimer = null;
|
|
1160
1386
|
}
|
|
1161
|
-
|
|
1387
|
+
// backoff reset is DEFERRED until the socket proves stable: a
|
|
1388
|
+
// server that accepts init then immediately drops would otherwise
|
|
1389
|
+
// reconnect once per second forever (~1 cycle/s per session-server
|
|
1390
|
+
// pair, live-caught in the optimization audit). 30s alive (socket
|
|
1391
|
+
// still ready, no reconnect pending) or a second acked sync.
|
|
1392
|
+
if (r.backoffResetTimer)
|
|
1393
|
+
clearTimeout(r.backoffResetTimer);
|
|
1394
|
+
r.backoffResetTimer = setTimeout(() => {
|
|
1395
|
+
r.backoffResetTimer = null;
|
|
1396
|
+
if (r.wsReady && !r.reconnectTimer)
|
|
1397
|
+
r.backoff = 1000;
|
|
1398
|
+
}, 30000);
|
|
1162
1399
|
st.everConnected = true;
|
|
1163
1400
|
if (frame.policy)
|
|
1164
1401
|
applyPolicy(frame.policy, r.url);
|
|
@@ -1184,19 +1421,70 @@ export function createEngine(deps) {
|
|
|
1184
1421
|
if (wasFanoutCreate) {
|
|
1185
1422
|
// a server joined an ALREADY-live session (fan-out add, or a
|
|
1186
1423
|
// post-mismatch recreate): its fresh share needs the full history —
|
|
1187
|
-
// backfill is keyed-idempotent, other servers just re-upsert
|
|
1424
|
+
// backfill is keyed-idempotent, other servers just re-upsert.
|
|
1425
|
+
// The enqueue dedupe must step aside: those hashes say "already
|
|
1426
|
+
// entered the machinery", but the NEW remote never received any
|
|
1427
|
+
// of it (live-caught by F3: the join's backfill was fully
|
|
1428
|
+
// deduped away and the joined share stayed empty).
|
|
1429
|
+
st.sentHash.clear();
|
|
1188
1430
|
chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
|
|
1189
1431
|
}
|
|
1190
1432
|
return;
|
|
1191
1433
|
}
|
|
1192
1434
|
if (frame.op === "ack") {
|
|
1193
1435
|
r.ackedRev = frame.rev ?? r.ackedRev;
|
|
1194
|
-
|
|
1195
|
-
|
|
1436
|
+
// a second acked sync also proves the socket stable — release the
|
|
1437
|
+
// backoff reset timer early (complements the 30s-alive timer)
|
|
1438
|
+
if (r.backoffResetTimer) {
|
|
1439
|
+
clearTimeout(r.backoffResetTimer);
|
|
1440
|
+
r.backoffResetTimer = null;
|
|
1441
|
+
r.backoff = 1000;
|
|
1442
|
+
}
|
|
1443
|
+
// seq-scoped retirement: an ack identifies the chunk it applied.
|
|
1444
|
+
// An ack WITHOUT seq = old server: legacy clear-all (correct there —
|
|
1445
|
+
// old servers acked the whole pending batch together).
|
|
1446
|
+
if (typeof frame.seq === "number") {
|
|
1447
|
+
r.pending = r.pending.filter((p) => p.seq !== frame.seq);
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
r.pending = [];
|
|
1451
|
+
}
|
|
1452
|
+
scheduleSpoolSave(st, r);
|
|
1196
1453
|
scheduleBindingWrite(st);
|
|
1197
1454
|
return;
|
|
1198
1455
|
}
|
|
1199
1456
|
if (frame.op === "nack" || frame.op === "error") {
|
|
1457
|
+
const errText = String(frame.error ?? "");
|
|
1458
|
+
if (frame.op === "nack") {
|
|
1459
|
+
// classify: the share dying server-side under a live socket
|
|
1460
|
+
// (sweeper TTL, admin delete, purge) surfaces as nacks — the
|
|
1461
|
+
// socket is "healthy" so nothing else recovers it. Same reset as
|
|
1462
|
+
// the init-refused path: fresh create + content replay.
|
|
1463
|
+
if (/not found|gone/i.test(errText)) {
|
|
1464
|
+
logError(`${r.url}: share vanished server-side (nack: ${errText}) - recreating`);
|
|
1465
|
+
deleteAbandoned(r);
|
|
1466
|
+
r.shareId = null;
|
|
1467
|
+
r.secret = null;
|
|
1468
|
+
r.ackedRev = 0;
|
|
1469
|
+
try {
|
|
1470
|
+
r.ws?.close(4000, "share gone");
|
|
1471
|
+
}
|
|
1472
|
+
catch { }
|
|
1473
|
+
if (!st.closed)
|
|
1474
|
+
queueConnect(st, r);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (/frozen/i.test(errText)) {
|
|
1478
|
+
// owner frozen mid-session: every write is refused. Stop
|
|
1479
|
+
// spooling NEW frames for this remote (unbounded growth) but
|
|
1480
|
+
// KEEP what's already queued — an unfreeze revives the remote
|
|
1481
|
+
// on the next successful handshake (init-ok clears the flag)
|
|
1482
|
+
// and the backlog replays from there.
|
|
1483
|
+
r.frozenRemote = true;
|
|
1484
|
+
logError(`${r.url}: share frozen by the hoster - edits refused; contact the server admin`);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1200
1488
|
logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200));
|
|
1201
1489
|
// the control socket lost its attach state (worker-side eviction of
|
|
1202
1490
|
// the control DO): force a reconnect — init attach replays pending
|
|
@@ -1227,6 +1515,10 @@ export function createEngine(deps) {
|
|
|
1227
1515
|
};
|
|
1228
1516
|
// ---- flush -------------------------------------------------------------
|
|
1229
1517
|
const frameFrom = (st) => {
|
|
1518
|
+
// stamps run at frame time: the whole family's data is indexed by now
|
|
1519
|
+
// (child prompts are created during the task call, well inside the
|
|
1520
|
+
// flush debounce), and any re-enqueued message lands in this frame
|
|
1521
|
+
stampsPass(st);
|
|
1230
1522
|
const items = Array.from(st.queue.values());
|
|
1231
1523
|
st.queue.clear();
|
|
1232
1524
|
const statusChanged = st.status !== st.lastSentStatus;
|
|
@@ -1251,18 +1543,41 @@ export function createEngine(deps) {
|
|
|
1251
1543
|
return;
|
|
1252
1544
|
// fan out to every remote; each carries its own pending copy and acks
|
|
1253
1545
|
// independently. Remotes mid-handshake or auth-blocked just buffer.
|
|
1546
|
+
// PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
|
|
1547
|
+
// chunk-frames so a partial-ack can never erase unsent chunks
|
|
1548
|
+
// (live-caught class: chunk-1 ack + socket death erased chunks 2..N).
|
|
1549
|
+
const chunks = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : [];
|
|
1254
1550
|
let httpWork;
|
|
1255
1551
|
for (const r of st.remotes.values()) {
|
|
1552
|
+
if (r.frozenRemote)
|
|
1553
|
+
continue; // refused server-side; stop spooling
|
|
1554
|
+
const fresh = [];
|
|
1555
|
+
if (chunks.length) {
|
|
1556
|
+
for (const c of chunks) {
|
|
1557
|
+
const seq = ++nextSeq;
|
|
1558
|
+
const cf = { items: c.items, status: f.status, seq, wire: syncFrameStr(c.json, f.status, seq) };
|
|
1559
|
+
fresh.push(cf);
|
|
1560
|
+
r.pending.push(cf);
|
|
1561
|
+
spoolAppend(st, r, cf);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
else {
|
|
1565
|
+
// status-only frame (empty items): one chunk-less frame
|
|
1566
|
+
const seq = ++nextSeq;
|
|
1567
|
+
const cf = { items: [], status: f.status, seq };
|
|
1568
|
+
fresh.push(cf);
|
|
1569
|
+
r.pending.push(cf);
|
|
1570
|
+
spoolAppend(st, r, cf);
|
|
1571
|
+
}
|
|
1256
1572
|
// never send syncs before the init ack: the worker's per-socket
|
|
1257
1573
|
// ControlDO has no attach until OUR init completes, and a racing
|
|
1258
1574
|
// sync dies as "unknown op" (live-caught: post-drop reconnects
|
|
1259
1575
|
// lost whole turns). The init-ok handler flushes pending itself.
|
|
1260
|
-
r.pending.push(f);
|
|
1261
|
-
spoolSave(st, r);
|
|
1262
1576
|
if (r.ws && !r.wsReady)
|
|
1263
1577
|
continue;
|
|
1264
1578
|
if (r.wsReady) {
|
|
1265
|
-
|
|
1579
|
+
for (const cf of fresh)
|
|
1580
|
+
sendSync(r, cf);
|
|
1266
1581
|
continue;
|
|
1267
1582
|
}
|
|
1268
1583
|
const viaHttp = flushViaHttp(st, r);
|
|
@@ -1271,36 +1586,84 @@ export function createEngine(deps) {
|
|
|
1271
1586
|
}
|
|
1272
1587
|
if (st.remotes.size)
|
|
1273
1588
|
st.lastFlushAt = Date.now();
|
|
1589
|
+
// the HTTP work runs detached from the flush chain (one dead server
|
|
1590
|
+
// must not stall the fan-out); dispose awaits its own final flush
|
|
1591
|
+
if (httpWork)
|
|
1592
|
+
void httpWork.catch((e) => logError("http flush error", e?.message || e));
|
|
1274
1593
|
return httpWork;
|
|
1275
1594
|
};
|
|
1595
|
+
/** Client-side key derivation mirroring the server's itemKey — used only
|
|
1596
|
+
* for deduping the HTTP fallback batch (keyed upserts: the LAST item
|
|
1597
|
+
* for a key wins, matching apply() semantics). */
|
|
1598
|
+
const httpDedupeKey = (it) => {
|
|
1599
|
+
if (!it || typeof it !== "object")
|
|
1600
|
+
return null;
|
|
1601
|
+
const o = it;
|
|
1602
|
+
switch (o.type) {
|
|
1603
|
+
case "session": return "session/" + (o.data?.id ?? "");
|
|
1604
|
+
case "session_diff": return "session_diff/" + (o.sessionID ?? o.data?.sessionID ?? "");
|
|
1605
|
+
case "message": return "message/" + (o.data?.id ?? "");
|
|
1606
|
+
case "part": return "part/" + (o.data?.messageID ?? "") + "/" + (o.data?.id ?? "");
|
|
1607
|
+
case "model": return "model/" + (o.sessionID ?? o.data?.sessionID ?? "");
|
|
1608
|
+
case "prefs": return "prefs";
|
|
1609
|
+
default: return null;
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1276
1612
|
const flushViaHttp = async (st, r) => {
|
|
1277
1613
|
if (!r.shareId || !r.pending.length)
|
|
1278
1614
|
return;
|
|
1279
1615
|
// ALL pending frames, merged (keyed upserts — later wins). Sending
|
|
1280
1616
|
// only the last frame dropped every earlier batch's items whenever
|
|
1281
1617
|
// the HTTP fallback carried more than one (live-caught class:
|
|
1282
|
-
// content loss on reconnect paths).
|
|
1283
|
-
|
|
1618
|
+
// content loss on reconnect paths). DEDUPE BY KEY before chunking:
|
|
1619
|
+
// a streaming message appearing in k pending frames shipped k
|
|
1620
|
+
// copies (up to 25x on a replay burst) — every duplicate was also a
|
|
1621
|
+
// server row-write + a rebroadcast frame.
|
|
1622
|
+
const byKey = new Map();
|
|
1623
|
+
const unkeyed = [];
|
|
1284
1624
|
let status;
|
|
1625
|
+
const sentSeqs = new Set();
|
|
1285
1626
|
for (const f of r.pending) {
|
|
1286
|
-
for (const it of f.items)
|
|
1287
|
-
|
|
1627
|
+
for (const it of f.items) {
|
|
1628
|
+
const k = httpDedupeKey(it);
|
|
1629
|
+
if (k)
|
|
1630
|
+
byKey.set(k, it);
|
|
1631
|
+
else
|
|
1632
|
+
unkeyed.push(it);
|
|
1633
|
+
}
|
|
1288
1634
|
if (f.status)
|
|
1289
1635
|
status = f.status;
|
|
1636
|
+
if (typeof f.seq === "number")
|
|
1637
|
+
sentSeqs.add(f.seq);
|
|
1290
1638
|
}
|
|
1639
|
+
const items = [...byKey.values(), ...unkeyed];
|
|
1291
1640
|
const cred = credOf(r.url);
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1641
|
+
// chunked like WS frames: one unbounded body is a whole-backlog
|
|
1642
|
+
// bomb after a mid-backfill socket drop (backfill = full history).
|
|
1643
|
+
// Failure stops the loop with pending intact — replaying already-
|
|
1644
|
+
// applied chunks is idempotent.
|
|
1645
|
+
// status-only frames (items empty, e.g. the terminal completed
|
|
1646
|
+
// flush in HTTP-only mode) still need ONE request or the status
|
|
1647
|
+
// never lands (the share stays "live" forever)
|
|
1648
|
+
const chunks = items.length ? chunkItems(items, 500, 4 * 1024 * 1024) : [[]];
|
|
1649
|
+
for (const chunk of chunks) {
|
|
1650
|
+
const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
|
|
1651
|
+
method: "POST",
|
|
1652
|
+
headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
|
|
1653
|
+
body: JSON.stringify({ secret: r.secret ?? "", data: chunk, status }),
|
|
1654
|
+
signal: AbortSignal.timeout(15000),
|
|
1655
|
+
}).catch(() => null);
|
|
1656
|
+
if (!res || !res.ok)
|
|
1657
|
+
return;
|
|
1299
1658
|
const body = (await res.json().catch(() => ({})));
|
|
1300
1659
|
r.ackedRev = body.rev ?? r.ackedRev;
|
|
1301
|
-
r.pending = [];
|
|
1302
|
-
spoolSave(st, r);
|
|
1303
1660
|
}
|
|
1661
|
+
// retire EXACTLY what this run sent: a concurrent flush may have
|
|
1662
|
+
// queued new frames while our fetches were in flight (the HTTP work
|
|
1663
|
+
// is deliberately detached from the flush chain) — a blanket clear
|
|
1664
|
+
// would erase them before anything transmitted them
|
|
1665
|
+
r.pending = r.pending.filter((p) => !sentSeqs.has(p.seq));
|
|
1666
|
+
scheduleSpoolSave(st, r);
|
|
1304
1667
|
};
|
|
1305
1668
|
const scheduleFlush = (st) => {
|
|
1306
1669
|
if (st.timer)
|
|
@@ -1316,19 +1679,358 @@ export function createEngine(deps) {
|
|
|
1316
1679
|
st.timer = null;
|
|
1317
1680
|
if (debugEvents)
|
|
1318
1681
|
log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`);
|
|
1319
|
-
|
|
1682
|
+
// NOTE: the flush's HTTP-fallback promise is deliberately NOT
|
|
1683
|
+
// chained — one black-holing server (15s timeout per chunk) must
|
|
1684
|
+
// not stall every other remote's flushes behind it. Dispose awaits
|
|
1685
|
+
// its own final flush explicitly.
|
|
1686
|
+
chain = chain
|
|
1687
|
+
.then(() => {
|
|
1688
|
+
flush(st);
|
|
1689
|
+
})
|
|
1690
|
+
.catch((e) => logError("flush error", e?.message || e));
|
|
1320
1691
|
}, wait);
|
|
1321
1692
|
};
|
|
1322
1693
|
const enqueue = (st, entry) => {
|
|
1323
1694
|
if (!st || !entry)
|
|
1324
1695
|
return;
|
|
1696
|
+
// content dedupe at the machinery boundary: an identical re-feed has
|
|
1697
|
+
// either entered the queue already or been flushed into every
|
|
1698
|
+
// remote's pending/spool (the per-remote delivery machinery owns it
|
|
1699
|
+
// from there) — re-sending it bumps rev, rewrites storage and
|
|
1700
|
+
// re-folds every viewer for nothing. This is the engine-side home
|
|
1701
|
+
// the v2-adapter dedupe lacked (an adapter-side cache poisoned while
|
|
1702
|
+
// dormant and skipped the flag-time content — live-caught).
|
|
1703
|
+
let h;
|
|
1704
|
+
try {
|
|
1705
|
+
h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex");
|
|
1706
|
+
}
|
|
1707
|
+
catch {
|
|
1708
|
+
h = "";
|
|
1709
|
+
}
|
|
1710
|
+
if (h && st.sentHash.get(entry.key) === h) {
|
|
1711
|
+
if (debugEvents)
|
|
1712
|
+
log(`enqueue dedupe ${entry.item.type} ${entry.key.slice(0, 40)}`);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
if (h)
|
|
1716
|
+
st.sentHash.set(entry.key, h);
|
|
1325
1717
|
st.queue.set(entry.key, entry.item);
|
|
1718
|
+
indexMessage(entry);
|
|
1326
1719
|
if (debugEvents)
|
|
1327
1720
|
log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`);
|
|
1328
1721
|
touch(st);
|
|
1329
1722
|
ensureConnected(st);
|
|
1330
1723
|
scheduleFlush(st);
|
|
1331
1724
|
};
|
|
1725
|
+
const msgIndex = new Map(); // `${sid}:${messageID}`
|
|
1726
|
+
/** message ids tombstoned this run: stampsPass must never re-upsert an
|
|
1727
|
+
* undone message (live-caught class: dropMergedMessage removed the
|
|
1728
|
+
* merged entry but msgIndex kept the corpse; a neighboring-segment
|
|
1729
|
+
* change recomputed its stamps and reEnqueueMessage resurrected the
|
|
1730
|
+
* deleted turn server-side). */
|
|
1731
|
+
const tombstoned = new Set();
|
|
1732
|
+
const indexMessage = (entry) => {
|
|
1733
|
+
if (entry.item.type !== "message")
|
|
1734
|
+
return;
|
|
1735
|
+
const d = entry.item.data;
|
|
1736
|
+
if (!d || typeof d.id !== "string")
|
|
1737
|
+
return;
|
|
1738
|
+
const role = String(d.type ?? "");
|
|
1739
|
+
if (role !== "user" && role !== "assistant")
|
|
1740
|
+
return;
|
|
1741
|
+
const key = `${entry.item.sessionID}:${d.id}`;
|
|
1742
|
+
const prev = msgIndex.get(key);
|
|
1743
|
+
msgIndex.set(key, {
|
|
1744
|
+
sid: String(entry.item.sessionID ?? ""),
|
|
1745
|
+
role: role,
|
|
1746
|
+
agent: role === "assistant"
|
|
1747
|
+
? typeof d.agent === "string"
|
|
1748
|
+
? d.agent
|
|
1749
|
+
: prev?.agent
|
|
1750
|
+
: typeof d.metadata?.agent === "string"
|
|
1751
|
+
? d.metadata.agent
|
|
1752
|
+
: prev?.agent,
|
|
1753
|
+
created: Number(d.time?.created ?? prev?.created ?? 0),
|
|
1754
|
+
data: d,
|
|
1755
|
+
});
|
|
1756
|
+
};
|
|
1757
|
+
const sessionStateOf = (sid) => sessions.get(childToParent.get(sid) ?? sid);
|
|
1758
|
+
const reEnqueueMessage = (m) => {
|
|
1759
|
+
// tombstone guard: an undone message stays dead even if a stamp pass
|
|
1760
|
+
// recomputes its segment metadata
|
|
1761
|
+
if (tombstoned.has(`${m.sid}:${m.data.id}`))
|
|
1762
|
+
return;
|
|
1763
|
+
const st = sessionStateOf(m.sid);
|
|
1764
|
+
if (st)
|
|
1765
|
+
enqueue(st, itemFor("message", m.data, m.sid));
|
|
1766
|
+
};
|
|
1767
|
+
/** segment agent for a user prompt: the prompt's own recorded agent (v1
|
|
1768
|
+
* hosts stamp it on every user message), else the first assistant
|
|
1769
|
+
* response inside the segment (v2 hosts), else carry-forward. */
|
|
1770
|
+
const segmentAgent = (ordered, at) => {
|
|
1771
|
+
const own = ordered[at]?.agent;
|
|
1772
|
+
if (own)
|
|
1773
|
+
return own;
|
|
1774
|
+
for (let i = at + 1; i < ordered.length; i++) {
|
|
1775
|
+
if (ordered[i].role === "assistant" && ordered[i].agent)
|
|
1776
|
+
return ordered[i].agent;
|
|
1777
|
+
if (ordered[i].role === "user")
|
|
1778
|
+
break; // next segment already began
|
|
1779
|
+
}
|
|
1780
|
+
return undefined;
|
|
1781
|
+
};
|
|
1782
|
+
/** user messages whose segment runs a DIFFERENT agent than the previous
|
|
1783
|
+
* segment carry metadata.agentSwitch = <agent> - the viewer renders the
|
|
1784
|
+
* transition chip from the stamp alone. */
|
|
1785
|
+
const stampAgentSwitches = (bySession) => {
|
|
1786
|
+
for (const [, list] of bySession) {
|
|
1787
|
+
list.sort((a, b) => a.created - b.created);
|
|
1788
|
+
const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0);
|
|
1789
|
+
let prev;
|
|
1790
|
+
for (let k = 0; k < users.length; k++) {
|
|
1791
|
+
const agent = segmentAgent(list, users[k]);
|
|
1792
|
+
const d = list[users[k]].data;
|
|
1793
|
+
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
|
|
1794
|
+
// only touch metadata when the stamp actually CHANGES — a blanket
|
|
1795
|
+
// `d.metadata ??= {}` polluted every user message (including the
|
|
1796
|
+
// host's live objects on v2 pass-through) with an empty metadata
|
|
1797
|
+
// object, which re-emitted the message once per refresh (rev churn
|
|
1798
|
+
// per feed — live-caught by the enqueue-dedupe rev check)
|
|
1799
|
+
if (want !== d.metadata?.agentSwitch) {
|
|
1800
|
+
if (want) {
|
|
1801
|
+
const meta = (d.metadata ??= {});
|
|
1802
|
+
meta.agentSwitch = want;
|
|
1803
|
+
}
|
|
1804
|
+
else if (d.metadata) {
|
|
1805
|
+
delete d.metadata.agentSwitch;
|
|
1806
|
+
}
|
|
1807
|
+
reEnqueueMessage(list[users[k]]);
|
|
1808
|
+
}
|
|
1809
|
+
if (agent)
|
|
1810
|
+
prev = agent;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
/** task/subagent tool calls get metadata.agent = the agent that actually
|
|
1815
|
+
* ran for THAT call: the child's first prompt created at/after the call
|
|
1816
|
+
* started (v1 prompts carry the agent; v2 resolves via the response).
|
|
1817
|
+
* Rank-free pairing - a resumed child's second call lands on its second
|
|
1818
|
+
* prompt by construction time. */
|
|
1819
|
+
const stampTaskAgents = (bySession) => {
|
|
1820
|
+
for (const list of bySession.values())
|
|
1821
|
+
list.sort((a, b) => a.created - b.created);
|
|
1822
|
+
for (const m of msgIndex.values()) {
|
|
1823
|
+
if (m.role !== "assistant")
|
|
1824
|
+
continue;
|
|
1825
|
+
const content = m.data.content;
|
|
1826
|
+
if (!Array.isArray(content))
|
|
1827
|
+
continue;
|
|
1828
|
+
let mutated = false;
|
|
1829
|
+
for (const part of content) {
|
|
1830
|
+
if (!part || part.type !== "tool")
|
|
1831
|
+
continue;
|
|
1832
|
+
if (part.name !== "task" && part.name !== "subagent")
|
|
1833
|
+
continue;
|
|
1834
|
+
const state = part.state;
|
|
1835
|
+
const meta = state?.metadata;
|
|
1836
|
+
const childSid = typeof meta?.sessionID === "string" ? meta.sessionID : undefined;
|
|
1837
|
+
if (!childSid || typeof meta.agent === "string")
|
|
1838
|
+
continue;
|
|
1839
|
+
const partStart = Number(part.time?.created ?? 0);
|
|
1840
|
+
const child = bySession.get(childSid);
|
|
1841
|
+
if (!child)
|
|
1842
|
+
continue;
|
|
1843
|
+
let agent;
|
|
1844
|
+
for (const c of child) {
|
|
1845
|
+
if (c.created < partStart)
|
|
1846
|
+
continue;
|
|
1847
|
+
if (c.role === "user" && c.agent) {
|
|
1848
|
+
agent = c.agent;
|
|
1849
|
+
break;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (!agent) {
|
|
1853
|
+
for (const c of child) {
|
|
1854
|
+
if (c.created < partStart)
|
|
1855
|
+
continue;
|
|
1856
|
+
if (c.role === "assistant" && c.agent) {
|
|
1857
|
+
agent = c.agent;
|
|
1858
|
+
break;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
if (agent) {
|
|
1863
|
+
meta.agent = agent;
|
|
1864
|
+
mutated = true;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
if (mutated)
|
|
1868
|
+
reEnqueueMessage(m);
|
|
1869
|
+
}
|
|
1870
|
+
};
|
|
1871
|
+
/** file tools get metadata.language (shiki lang id, from the path the
|
|
1872
|
+
* call touched) - the viewer highlights from the stamp and never guesses
|
|
1873
|
+
* (owner directive; the viewer's extension map is an old-share fallback) */
|
|
1874
|
+
const LANG_BY_EXT = {
|
|
1875
|
+
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
|
1876
|
+
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
1877
|
+
json: "json", jsonc: "json", md: "markdown", mdx: "mdx",
|
|
1878
|
+
py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
|
|
1879
|
+
css: "css", scss: "scss", html: "html", xml: "xml", yaml: "yaml", yml: "yaml",
|
|
1880
|
+
toml: "toml", sh: "shellscript", bash: "shellscript", ps1: "powershell",
|
|
1881
|
+
sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
|
|
1882
|
+
swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
|
|
1883
|
+
};
|
|
1884
|
+
const FILE_TOOLS = new Set(["read", "write", "edit", "patch"]);
|
|
1885
|
+
const stampFileLanguages = () => {
|
|
1886
|
+
for (const m of msgIndex.values()) {
|
|
1887
|
+
if (m.role !== "assistant")
|
|
1888
|
+
continue;
|
|
1889
|
+
const content = m.data.content;
|
|
1890
|
+
if (!Array.isArray(content))
|
|
1891
|
+
continue;
|
|
1892
|
+
let mutated = false;
|
|
1893
|
+
for (const part of content) {
|
|
1894
|
+
if (!part || part.type !== "tool")
|
|
1895
|
+
continue;
|
|
1896
|
+
if (!FILE_TOOLS.has(part.name))
|
|
1897
|
+
continue;
|
|
1898
|
+
const state = part.state;
|
|
1899
|
+
const meta = state?.metadata;
|
|
1900
|
+
if (!meta)
|
|
1901
|
+
continue;
|
|
1902
|
+
const raw = state.input?.path ?? state.input?.filePath;
|
|
1903
|
+
// language stamp (shiki id from the path)
|
|
1904
|
+
if (typeof meta.language !== "string" && typeof raw === "string") {
|
|
1905
|
+
const ext = raw.split(".").pop()?.toLowerCase() ?? "";
|
|
1906
|
+
const lang = LANG_BY_EXT[ext];
|
|
1907
|
+
if (lang) {
|
|
1908
|
+
meta.language = lang;
|
|
1909
|
+
mutated = true;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
// lineStart stamp: reads sliced from an offset keep REAL file line
|
|
1913
|
+
// numbers in the viewer's unified file card (pierre numbers from 1;
|
|
1914
|
+
// the viewer shifts by this stamp, authoritatively)
|
|
1915
|
+
if (typeof meta.lineStart !== "number" && part.name === "read") {
|
|
1916
|
+
const off = state.input?.offset;
|
|
1917
|
+
if (typeof off === "number" && off >= 0 && off > 0) {
|
|
1918
|
+
meta.lineStart = off + 1;
|
|
1919
|
+
mutated = true;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
if (mutated)
|
|
1924
|
+
reEnqueueMessage(m);
|
|
1925
|
+
}
|
|
1926
|
+
};
|
|
1927
|
+
const stampsPass = (st) => {
|
|
1928
|
+
// skip entirely when this flush carries no message items: every
|
|
1929
|
+
// index mutation rides a message item through the queue (inserts via
|
|
1930
|
+
// enqueue, deletes via tombstones — both are type "message"), so no
|
|
1931
|
+
// message in the queue means the stamps' inputs are unchanged. A
|
|
1932
|
+
// status-only/model/prefs/diff flush costs zero stamp work.
|
|
1933
|
+
let any = false;
|
|
1934
|
+
for (const it of st.queue.values()) {
|
|
1935
|
+
if (it.type === "message") {
|
|
1936
|
+
any = true;
|
|
1937
|
+
break;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
if (!any)
|
|
1941
|
+
return;
|
|
1942
|
+
// one grouping + one sort shared by both passes (each used to build
|
|
1943
|
+
// and sort its own identical bySession map per flush)
|
|
1944
|
+
const bySession = new Map();
|
|
1945
|
+
for (const m of msgIndex.values()) {
|
|
1946
|
+
const list = bySession.get(m.sid);
|
|
1947
|
+
if (list)
|
|
1948
|
+
list.push(m);
|
|
1949
|
+
else
|
|
1950
|
+
bySession.set(m.sid, [m]);
|
|
1951
|
+
}
|
|
1952
|
+
for (const list of bySession.values())
|
|
1953
|
+
list.sort((a, b) => a.created - b.created);
|
|
1954
|
+
stampAgentSwitches(bySession);
|
|
1955
|
+
stampTaskAgents(bySession);
|
|
1956
|
+
stampFileLanguages();
|
|
1957
|
+
};
|
|
1958
|
+
// ---- contract v2 message assembly --------------------------------------
|
|
1959
|
+
// v1 hosts stream parts as separate events; the v2 item embeds content.
|
|
1960
|
+
// The engine keeps the assembled view per message and re-emits the
|
|
1961
|
+
// normalized whole (the keyed queue dedupes across a debounce window,
|
|
1962
|
+
// so intermediate re-emissions collapse).
|
|
1963
|
+
const mergeEntry = (st, messageID) => {
|
|
1964
|
+
let e = st.merged.get(messageID);
|
|
1965
|
+
if (!e) {
|
|
1966
|
+
e = { parts: new Map() };
|
|
1967
|
+
st.merged.set(messageID, e);
|
|
1968
|
+
}
|
|
1969
|
+
return e;
|
|
1970
|
+
};
|
|
1971
|
+
/** (Re-)emit the v2 message item for a merged v1 message. */
|
|
1972
|
+
const emitMerged = (st, sid, messageID) => {
|
|
1973
|
+
const e = st.merged.get(messageID);
|
|
1974
|
+
if (!e?.info)
|
|
1975
|
+
return; // parts arriving before their message: wait
|
|
1976
|
+
const res = normalizeV1Message(e.info, [...e.parts.values()]);
|
|
1977
|
+
if (!res)
|
|
1978
|
+
return;
|
|
1979
|
+
if ("refill" in res) {
|
|
1980
|
+
// v1 compaction summary assistant → rewrite the parent compaction
|
|
1981
|
+
// message with the summary text; the summary message itself is not
|
|
1982
|
+
// emitted as its own item
|
|
1983
|
+
const parent = st.merged.get(res.refill);
|
|
1984
|
+
if (parent?.info) {
|
|
1985
|
+
const compaction = normalizeV1Message(parent.info, [...parent.parts.values()]);
|
|
1986
|
+
if (compaction && "message" in compaction) {
|
|
1987
|
+
const msg = compaction.message;
|
|
1988
|
+
msg.summary = res.summary;
|
|
1989
|
+
enqueue(st, itemFor("message", msg, sid));
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
enqueue(st, itemFor("message", res.message, sid));
|
|
1995
|
+
};
|
|
1996
|
+
/** message.updated / part.updated ingestion for v1 hosts; v2 hosts call
|
|
1997
|
+
* with v2-native messages (pass-through, no merge). */
|
|
1998
|
+
const ingestMessageInfo = (st, sid, info) => {
|
|
1999
|
+
if (isV2NativeMessage(info)) {
|
|
2000
|
+
enqueue(st, itemFor("message", info, sid));
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
if (typeof info?.id !== "string")
|
|
2004
|
+
return;
|
|
2005
|
+
const e = mergeEntry(st, info.id);
|
|
2006
|
+
e.info = info;
|
|
2007
|
+
emitMerged(st, sid, info.id);
|
|
2008
|
+
};
|
|
2009
|
+
const ingestPart = (st, sid, part) => {
|
|
2010
|
+
if (typeof part?.id !== "string" || typeof part.messageID !== "string")
|
|
2011
|
+
return;
|
|
2012
|
+
const e = mergeEntry(st, part.messageID);
|
|
2013
|
+
e.parts.set(part.id, part);
|
|
2014
|
+
emitMerged(st, sid, part.messageID);
|
|
2015
|
+
};
|
|
2016
|
+
const dropMergedMessage = (st, messageID, evSid) => {
|
|
2017
|
+
// the index corpse must go too: stampsPass iterates msgIndex, and a
|
|
2018
|
+
// re-stamp of a deleted message would re-upsert it (resurrection).
|
|
2019
|
+
// Key by the EVENT's session (children tombstone under their own sid;
|
|
2020
|
+
// the merged map is per main session)
|
|
2021
|
+
const sid = evSid ?? st.sid;
|
|
2022
|
+
st.merged.delete(messageID);
|
|
2023
|
+
msgIndex.delete(`${sid}:${messageID}`);
|
|
2024
|
+
tombstoned.add(`${sid}:${messageID}`);
|
|
2025
|
+
};
|
|
2026
|
+
const dropMergedPart = (st, sid, messageID, partID) => {
|
|
2027
|
+
const e = st.merged.get(messageID);
|
|
2028
|
+
if (!e)
|
|
2029
|
+
return;
|
|
2030
|
+
e.parts.delete(partID);
|
|
2031
|
+
if (e.info)
|
|
2032
|
+
emitMerged(st, sid, messageID);
|
|
2033
|
+
};
|
|
1332
2034
|
const remove = (st) => {
|
|
1333
2035
|
st.closed = true;
|
|
1334
2036
|
st.activated = false;
|
|
@@ -1347,6 +2049,10 @@ export function createEngine(deps) {
|
|
|
1347
2049
|
clearTimeout(r.initTimer);
|
|
1348
2050
|
r.initTimer = null;
|
|
1349
2051
|
}
|
|
2052
|
+
if (r.backoffResetTimer) {
|
|
2053
|
+
clearTimeout(r.backoffResetTimer);
|
|
2054
|
+
r.backoffResetTimer = null;
|
|
2055
|
+
}
|
|
1350
2056
|
if (r.reconnectTimer) {
|
|
1351
2057
|
clearTimeout(r.reconnectTimer);
|
|
1352
2058
|
r.reconnectTimer = null;
|
|
@@ -1356,8 +2062,28 @@ export function createEngine(deps) {
|
|
|
1356
2062
|
clearTimeout(r.idleDropTimer);
|
|
1357
2063
|
r.idleDropTimer = null;
|
|
1358
2064
|
}
|
|
1359
|
-
|
|
2065
|
+
// wsReady delete only when THIS remote still owns a share: a stray
|
|
2066
|
+
// child teardown clears shareId after adopting into the parent —
|
|
2067
|
+
// the ControlDO's server-side attach would otherwise delete the
|
|
2068
|
+
// very share the parent just inherited (live-caught adoption churn)
|
|
2069
|
+
if (r.wsReady && r.shareId) {
|
|
1360
2070
|
wsSend(r, { op: "delete" });
|
|
2071
|
+
// backstop: the WS delete is fire-and-forget on the wire — a
|
|
2072
|
+
// socket dying right after leaves the page public until TTL. The
|
|
2073
|
+
// HTTP goodbye is idempotent (404/ok both fine).
|
|
2074
|
+
if (r.shareId && r.secret) {
|
|
2075
|
+
const sid = r.shareId;
|
|
2076
|
+
const sec = r.secret;
|
|
2077
|
+
setTimeout(() => {
|
|
2078
|
+
void fetch(`${r.url}/api/share/${sid}`, {
|
|
2079
|
+
method: "DELETE",
|
|
2080
|
+
headers: { "content-type": "application/json" },
|
|
2081
|
+
body: JSON.stringify({ secret: sec }),
|
|
2082
|
+
signal: AbortSignal.timeout(8000),
|
|
2083
|
+
}).catch(() => { });
|
|
2084
|
+
}, 2000);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
1361
2087
|
else if (r.shareId && r.secret) {
|
|
1362
2088
|
// turn mode may have dropped the socket after the last settle; the
|
|
1363
2089
|
// goodbye still must land — say it over HTTP
|
|
@@ -1379,6 +2105,21 @@ export function createEngine(deps) {
|
|
|
1379
2105
|
}, 1000);
|
|
1380
2106
|
for (const r of st.remotes.values())
|
|
1381
2107
|
spoolClear(st, r);
|
|
2108
|
+
// memory hygiene: the session is gone — its index corpses, tombstone
|
|
2109
|
+
// guards and child routing must not linger (msgIndex/tombstoned are
|
|
2110
|
+
// engine-global and otherwise only grow; sentHash rides the session
|
|
2111
|
+
// state and dies with it)
|
|
2112
|
+
const stSid = st.sid;
|
|
2113
|
+
const pfx = `${stSid}:`;
|
|
2114
|
+
for (const k of msgIndex.keys())
|
|
2115
|
+
if (k.startsWith(pfx))
|
|
2116
|
+
msgIndex.delete(k);
|
|
2117
|
+
for (const k of tombstoned)
|
|
2118
|
+
if (k.startsWith(pfx))
|
|
2119
|
+
tombstoned.delete(k);
|
|
2120
|
+
for (const [child, parent] of childToParent)
|
|
2121
|
+
if (parent === stSid || child === stSid)
|
|
2122
|
+
childToParent.delete(child);
|
|
1382
2123
|
sessions.delete(st.sid);
|
|
1383
2124
|
try {
|
|
1384
2125
|
chain = chain.then(async () => {
|
|
@@ -1413,7 +2154,7 @@ export function createEngine(deps) {
|
|
|
1413
2154
|
const PAGE_SIZE = 100;
|
|
1414
2155
|
const MAX_PAGES = 50;
|
|
1415
2156
|
const query = () => (deps.directory ? { directory: deps.directory } : {});
|
|
1416
|
-
async function fetchMessages(client, sid) {
|
|
2157
|
+
async function fetchMessages(client, sid, onPage) {
|
|
1417
2158
|
if (typeof client.session.messages !== "function")
|
|
1418
2159
|
return [];
|
|
1419
2160
|
const out = [];
|
|
@@ -1429,41 +2170,69 @@ export function createEngine(deps) {
|
|
|
1429
2170
|
if (!Array.isArray(result) || result.length === 0)
|
|
1430
2171
|
break;
|
|
1431
2172
|
out.push(...result);
|
|
2173
|
+
// stream pages as they arrive: enqueue (and the rate-limited flush)
|
|
2174
|
+
// starts with page 1 while later pages are still loading — a huge
|
|
2175
|
+
// session backfills progressively instead of after a full collect
|
|
2176
|
+
if (onPage) {
|
|
2177
|
+
try {
|
|
2178
|
+
onPage(result);
|
|
2179
|
+
}
|
|
2180
|
+
catch { }
|
|
2181
|
+
}
|
|
1432
2182
|
if (result.length < PAGE_SIZE)
|
|
1433
2183
|
break;
|
|
1434
2184
|
const last = result[result.length - 1];
|
|
1435
|
-
const
|
|
1436
|
-
|
|
2185
|
+
const lastInfo = (last?.info ?? last);
|
|
2186
|
+
const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined;
|
|
2187
|
+
// `before` is an OPAQUE cursor: base64url({id, time}) of the page
|
|
2188
|
+
// tail (message-v2.ts codec). A bare message id 400s on hosts with
|
|
2189
|
+
// cursor pagination — every v1 backfill silently truncated to one
|
|
2190
|
+
// 100-message page (live-caught).
|
|
2191
|
+
const lastTime = lastInfo?.time?.created;
|
|
2192
|
+
if (!lastID || typeof lastTime !== "number")
|
|
2193
|
+
break;
|
|
2194
|
+
const nextBefore = cursorFor(lastID, lastTime);
|
|
2195
|
+
if (nextBefore === before)
|
|
1437
2196
|
break;
|
|
1438
2197
|
before = nextBefore;
|
|
1439
2198
|
}
|
|
2199
|
+
// the page cap is a hard truncation: surface it instead of silently
|
|
2200
|
+
// sharing a partial history (oldest pages dropped)
|
|
2201
|
+
if (out.length >= MAX_PAGES * PAGE_SIZE) {
|
|
2202
|
+
logError(`backfill truncated at ${out.length} messages (page cap) for ${sid.slice(0, 18)} — oldest history not shared`);
|
|
2203
|
+
}
|
|
1440
2204
|
return out;
|
|
1441
2205
|
}
|
|
1442
2206
|
const backfillSession = async (st, client, sid, info) => {
|
|
1443
2207
|
const sessionInfoValue = info ?? (await sessionInfo(client, sid));
|
|
1444
2208
|
if (sessionInfoValue)
|
|
1445
2209
|
enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
|
|
1446
|
-
const messages = await fetchMessages(client, sid);
|
|
1447
2210
|
let modelSeen = false;
|
|
1448
|
-
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
enqueue(st, itemFor("message", m, sid));
|
|
1453
|
-
if (m.role === "user" && m.model) {
|
|
1454
|
-
modelSeen = true;
|
|
1455
|
-
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
|
|
1456
|
-
enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
|
|
1457
|
-
}
|
|
1458
|
-
for (const part of message.parts ?? []) {
|
|
1459
|
-
const p = part;
|
|
1460
|
-
if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
|
|
2211
|
+
const consume = (messages) => {
|
|
2212
|
+
for (const message of messages) {
|
|
2213
|
+
const m = (message.info ?? message);
|
|
2214
|
+
if (!m || typeof m.id !== "string")
|
|
1461
2215
|
continue;
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
2216
|
+
// seed the merge state, then emit the assembled v2 message
|
|
2217
|
+
const e = mergeEntry(st, m.id);
|
|
2218
|
+
e.info = m;
|
|
2219
|
+
for (const rawPart of (message.parts ?? [])) {
|
|
2220
|
+
const p = rawPart;
|
|
2221
|
+
if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
|
|
2222
|
+
continue;
|
|
2223
|
+
if (p.state?.status === "error")
|
|
2224
|
+
st.badSignal = true;
|
|
2225
|
+
e.parts.set(p.id, p);
|
|
2226
|
+
}
|
|
2227
|
+
emitMerged(st, sid, m.id);
|
|
2228
|
+
if (m.role === "user" && m.model) {
|
|
2229
|
+
modelSeen = true;
|
|
2230
|
+
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
|
|
2231
|
+
enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
|
|
2232
|
+
}
|
|
1465
2233
|
}
|
|
1466
|
-
}
|
|
2234
|
+
};
|
|
2235
|
+
const messages = await fetchMessages(client, sid, (page) => consume(page));
|
|
1467
2236
|
if (!modelSeen && sessionInfoValue?.model) {
|
|
1468
2237
|
// hosts whose user messages carry no model (v2): label from the
|
|
1469
2238
|
// session's own model
|
|
@@ -1725,8 +2494,13 @@ export function createEngine(deps) {
|
|
|
1725
2494
|
if (st.timer)
|
|
1726
2495
|
clearTimeout(st.timer);
|
|
1727
2496
|
st.timer = null;
|
|
2497
|
+
// detached flush (HTTP fallback off the shared chain — one
|
|
2498
|
+
// black-holing server must not stall the fan-out; same rule
|
|
2499
|
+
// as scheduleFlush)
|
|
1728
2500
|
chain = chain
|
|
1729
|
-
.then(() =>
|
|
2501
|
+
.then(() => {
|
|
2502
|
+
flush(st);
|
|
2503
|
+
})
|
|
1730
2504
|
.catch((e) => logError("idle flush error", e?.message || e));
|
|
1731
2505
|
const epoch = st.statusEpoch;
|
|
1732
2506
|
const settle = () => {
|
|
@@ -1736,7 +2510,9 @@ export function createEngine(deps) {
|
|
|
1736
2510
|
clearTimeout(st.timer);
|
|
1737
2511
|
st.timer = null;
|
|
1738
2512
|
chain = chain
|
|
1739
|
-
.then(() =>
|
|
2513
|
+
.then(() => {
|
|
2514
|
+
flush(st);
|
|
2515
|
+
})
|
|
1740
2516
|
.then(() => {
|
|
1741
2517
|
armSettleDrop(st);
|
|
1742
2518
|
})
|
|
@@ -1773,9 +2549,9 @@ export function createEngine(deps) {
|
|
|
1773
2549
|
// opencode undo: revert() only marks the session (the viewer
|
|
1774
2550
|
// hides the range); the NEXT prompt hard-deletes the reverted
|
|
1775
2551
|
// messages/parts via cleanup() and publishes these events.
|
|
1776
|
-
//
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
2552
|
+
// A message tombstone removes the whole v2 message (content is
|
|
2553
|
+
// embedded); a part removal re-emits the merged message minus
|
|
2554
|
+
// the part.
|
|
1779
2555
|
const st = stFor(sid);
|
|
1780
2556
|
if (!st || !st.activated || st.closed)
|
|
1781
2557
|
return;
|
|
@@ -1783,13 +2559,18 @@ export function createEngine(deps) {
|
|
|
1783
2559
|
if (!messageID)
|
|
1784
2560
|
return;
|
|
1785
2561
|
if (t === "message.removed") {
|
|
1786
|
-
|
|
2562
|
+
// key/tombstone by the EVENT's session id (a child's message
|
|
2563
|
+
// must not be keyed onto the parent — the worker deletes the
|
|
2564
|
+
// item but the viewer's tombstone key would miss and the
|
|
2565
|
+
// msgIndex corpse could re-upsert it)
|
|
2566
|
+
dropMergedMessage(st, messageID, sid);
|
|
2567
|
+
enqueue(st, { key: `message/${messageID}`, item: messageTombstone(sid, messageID) });
|
|
1787
2568
|
}
|
|
1788
2569
|
else {
|
|
1789
2570
|
const partID = p.partID;
|
|
1790
2571
|
if (!partID)
|
|
1791
2572
|
return;
|
|
1792
|
-
|
|
2573
|
+
dropMergedPart(st, sid, messageID, partID);
|
|
1793
2574
|
}
|
|
1794
2575
|
}
|
|
1795
2576
|
else if (t === "message.updated") {
|
|
@@ -1844,7 +2625,7 @@ export function createEngine(deps) {
|
|
|
1844
2625
|
}
|
|
1845
2626
|
if (!st || !st.activated || st.closed)
|
|
1846
2627
|
return;
|
|
1847
|
-
|
|
2628
|
+
ingestMessageInfo(st, sid, p.info);
|
|
1848
2629
|
const info = p.info;
|
|
1849
2630
|
if (info?.role === "user" && info?.model) {
|
|
1850
2631
|
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
|
|
@@ -1853,8 +2634,9 @@ export function createEngine(deps) {
|
|
|
1853
2634
|
// an assistant message that DIED (finish:"error") — e.g. all
|
|
1854
2635
|
// retries exhausted — surfaces as a 200 response with an errored
|
|
1855
2636
|
// message, NOT as session.error (live-caught in the suite); the
|
|
1856
|
-
// share must still say error
|
|
1857
|
-
|
|
2637
|
+
// share must still say error. Works for v2-native messages too
|
|
2638
|
+
// (finish lives on the message itself).
|
|
2639
|
+
if (info?.finish === "error") {
|
|
1858
2640
|
st.badSignal = true;
|
|
1859
2641
|
}
|
|
1860
2642
|
}
|
|
@@ -1865,13 +2647,13 @@ export function createEngine(deps) {
|
|
|
1865
2647
|
const part = p.part;
|
|
1866
2648
|
if (part?.state?.status === "error")
|
|
1867
2649
|
st.badSignal = true;
|
|
1868
|
-
|
|
2650
|
+
ingestPart(st, sid, part);
|
|
1869
2651
|
}
|
|
1870
2652
|
else if (t === "session.diff") {
|
|
1871
2653
|
const st = stFor(sid);
|
|
1872
2654
|
if (!st || !st.activated || st.closed)
|
|
1873
2655
|
return;
|
|
1874
|
-
enqueue(st, itemFor("session_diff", p.diff, sid));
|
|
2656
|
+
enqueue(st, itemFor("session_diff", normalizeV1Diff(p.diff), sid));
|
|
1875
2657
|
}
|
|
1876
2658
|
else if (t === "session.deleted") {
|
|
1877
2659
|
// a CHILD session ending (task done, gc) must NEVER take the
|
|
@@ -1925,6 +2707,16 @@ export function createEngine(deps) {
|
|
|
1925
2707
|
await flushViaHttp(st, r).catch(() => { });
|
|
1926
2708
|
}
|
|
1927
2709
|
}
|
|
2710
|
+
// resolve any pending debounced spool rewrite before exit: the
|
|
2711
|
+
// queue lines are already on disk (appends are sync), but the
|
|
2712
|
+
// meta's ackedRev and the compacted queue should be final
|
|
2713
|
+
for (const r of st.remotes.values()) {
|
|
2714
|
+
if (r.spoolSaveTimer) {
|
|
2715
|
+
clearTimeout(r.spoolSaveTimer);
|
|
2716
|
+
r.spoolSaveTimer = null;
|
|
2717
|
+
spoolSave(st, r);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
1928
2720
|
if (st.metaTimer)
|
|
1929
2721
|
clearTimeout(st.metaTimer);
|
|
1930
2722
|
st.metaTimer = null;
|