@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/src/core.ts
CHANGED
|
@@ -43,8 +43,11 @@
|
|
|
43
43
|
// secret must never reach viewers.
|
|
44
44
|
|
|
45
45
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
46
|
+
import { createHash } from "node:crypto"
|
|
46
47
|
import { join } from "node:path"
|
|
47
48
|
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
|
|
49
|
+
import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
|
|
50
|
+
import { queueBindingWrite } from "./binding-queue.js"
|
|
48
51
|
|
|
49
52
|
// --- minimal structural types (hand-written: the standalone bundle must
|
|
50
53
|
// not depend on the plugin SDK at runtime; shapes verified against
|
|
@@ -106,13 +109,21 @@ type RemoteState = {
|
|
|
106
109
|
initTimer: ReturnType<typeof setTimeout> | null
|
|
107
110
|
initWasAttach: boolean
|
|
108
111
|
backoff: number
|
|
112
|
+
/** deferred backoff reset: released after 30s of socket stability or a
|
|
113
|
+
* second acked sync — never right after init (init-then-drop servers
|
|
114
|
+
* would cycle at 1/s otherwise) */
|
|
115
|
+
backoffResetTimer: ReturnType<typeof setTimeout> | null
|
|
109
116
|
reconnectTimer: ReturnType<typeof setTimeout> | null
|
|
110
117
|
intentionalClose: boolean
|
|
111
118
|
connectQueued: boolean
|
|
112
119
|
/** identity dead-end for THIS server (frozen owner, unenrollable
|
|
113
120
|
* private server): stop reconnecting it; other servers unaffected. */
|
|
114
121
|
authBlocked: boolean
|
|
115
|
-
pending: Array<{ items: unknown[]; status: string }> // frames awaiting ack (spool)
|
|
122
|
+
pending: Array<{ items: unknown[]; status: string; seq?: number; wire?: string }> // chunk-frames awaiting ack (spool; wire = prebuilt frame string)
|
|
123
|
+
frozenRemote?: boolean // server froze the owner mid-session: stop writing
|
|
124
|
+
spoolAppendFailed?: boolean // last append errored: memory-only frame exists
|
|
125
|
+
spoolSaveTimer: ReturnType<typeof setTimeout> | null // debounced ack-path spool rewrite
|
|
126
|
+
authWaiter?: (() => void) | undefined // frame-auth handshake resolver
|
|
116
127
|
announced: boolean
|
|
117
128
|
settleTimer: ReturnType<typeof setTimeout> | null
|
|
118
129
|
idleDropTimer: ReturnType<typeof setTimeout> | null
|
|
@@ -148,6 +159,16 @@ type SessionState = {
|
|
|
148
159
|
/** any remote completed a handshake — used to detect LATER creates
|
|
149
160
|
* (fan-out adds, post-mismatch recreates) which need a history backfill */
|
|
150
161
|
everConnected: boolean
|
|
162
|
+
/** contract v2 merge state: v1 hosts stream parts separately; the v2
|
|
163
|
+
* item embeds content, so the engine holds the assembled view per
|
|
164
|
+
* message and re-emits the normalized whole on every update */
|
|
165
|
+
merged: Map<string, { info?: Record<string, unknown>; parts: Map<string, Record<string, unknown>> }>
|
|
166
|
+
/** content hash of the LAST item enqueued per key: identical re-feeds
|
|
167
|
+
* (v2 hosts re-sync the whole transcript on every refresh; unchanged
|
|
168
|
+
* session info on every session.updated) are skipped instead of being
|
|
169
|
+
* flushed to every remote, bumping rev and re-folding every viewer.
|
|
170
|
+
* Cleared on activation — re-activation re-backfills by design. */
|
|
171
|
+
sentHash: Map<string, string>
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
type Message = { info?: Record<string, unknown>; parts?: Record<string, unknown>[] }
|
|
@@ -239,10 +260,11 @@ type ItemEntry = { key: string; item: Record<string, unknown> }
|
|
|
239
260
|
function itemFor(type: string, data: unknown, sessionID: string): ItemEntry | undefined {
|
|
240
261
|
if (type === "model") return { key: `model/${sessionID}`, item: { type, sessionID, data } }
|
|
241
262
|
if (type === "prefs") return { key: "prefs", item: { type, data } }
|
|
242
|
-
if (type === "session") return { key: `session/${(data as SessionInfo)?.id ?? ""}`, item: { type, data } }
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
263
|
+
if (type === "session") return { key: `session/${(data as SessionInfo)?.id ?? ""}`, item: { type, sessionID, data } }
|
|
264
|
+
// v2 messages carry no sessionID inside data (v1 infos did); the item
|
|
265
|
+
// envelope stamps it so multi-session folds (parent + subagents) can
|
|
266
|
+
// group without guessing
|
|
267
|
+
if (type === "message") return { key: `message/${(data as MessageInfo)?.id ?? ""}`, item: { type, sessionID, data } }
|
|
246
268
|
if (type === "session_diff") return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } }
|
|
247
269
|
return undefined
|
|
248
270
|
}
|
|
@@ -414,6 +436,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
414
436
|
initTimer: null,
|
|
415
437
|
initWasAttach: false,
|
|
416
438
|
backoff: 1000,
|
|
439
|
+
backoffResetTimer: null,
|
|
440
|
+
spoolSaveTimer: null,
|
|
417
441
|
reconnectTimer: null,
|
|
418
442
|
intentionalClose: false,
|
|
419
443
|
connectQueued: false,
|
|
@@ -479,12 +503,19 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
479
503
|
if (b && typeof b === "object" && (b as { policy?: unknown }).policy) {
|
|
480
504
|
applyPolicy((b as { policy: unknown }).policy, url)
|
|
481
505
|
}
|
|
506
|
+
// frame auth capability: the server accepts the token as the
|
|
507
|
+
// first WS frame — lets us keep it off the query string (proxy
|
|
508
|
+
// and access logs otherwise capture the full credential)
|
|
509
|
+
if (b && typeof b === "object" && (b as { frameAuth?: unknown }).frameAuth) {
|
|
510
|
+
frameAuthUrls.add(url)
|
|
511
|
+
}
|
|
482
512
|
})
|
|
483
513
|
.catch(() => {
|
|
484
514
|
// unreachable worker: defaults stay; the init ack will correct us
|
|
485
515
|
policyAsked.delete(url)
|
|
486
516
|
})
|
|
487
517
|
}
|
|
518
|
+
const frameAuthUrls = new Set<string>()
|
|
488
519
|
const wsUrl = (url: string) => url.replace(/^http/, "ws")
|
|
489
520
|
|
|
490
521
|
// ---- spool root ---------------------------------------------------------
|
|
@@ -527,6 +558,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
527
558
|
activated: false,
|
|
528
559
|
wroteBinding: false,
|
|
529
560
|
everConnected: false,
|
|
561
|
+
merged: new Map(),
|
|
562
|
+
sentHash: new Map(),
|
|
530
563
|
})
|
|
531
564
|
|
|
532
565
|
const refreshConfig = () => {
|
|
@@ -565,6 +598,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
565
598
|
}
|
|
566
599
|
st.activated = true
|
|
567
600
|
st.opts = opts
|
|
601
|
+
// re-activation re-backfills the whole session (unshare -> share
|
|
602
|
+
// again): the enqueue dedupe must not skip the returning content
|
|
603
|
+
st.sentHash.clear()
|
|
568
604
|
const gated = mode === "manual" || opts2?.gated === true
|
|
569
605
|
if (gated) {
|
|
570
606
|
// seed the share status from the session's CURRENT state before
|
|
@@ -640,26 +676,31 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
640
676
|
|
|
641
677
|
async function writeMetadata(st: SessionState, value: Record<string, unknown> | null) {
|
|
642
678
|
if (!st.client) return
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
679
|
+
// the read-modify-write runs inside the shared per-session queue: the
|
|
680
|
+
// TUI patches the same metadata (opt-outs, collisions) and interleaved
|
|
681
|
+
// RMWs silently lose one side's update (see binding-queue.ts)
|
|
682
|
+
await queueBindingWrite(st.sid, async () => {
|
|
683
|
+
try {
|
|
684
|
+
const info = unwrap(
|
|
685
|
+
await st.client!.session.get({
|
|
686
|
+
path: { id: st.sid },
|
|
687
|
+
...(deps.directory ? { query: { directory: deps.directory } } : {}),
|
|
688
|
+
}),
|
|
689
|
+
)
|
|
690
|
+
const metadata = { ...(info?.metadata ?? {}) }
|
|
691
|
+
if (value === null) delete metadata[METADATA_KEY]
|
|
692
|
+
else metadata[METADATA_KEY] = value
|
|
693
|
+
unwrap(
|
|
694
|
+
await st.client!.session.update({
|
|
695
|
+
path: { id: st.sid },
|
|
696
|
+
body: { metadata },
|
|
697
|
+
...(deps.directory ? { query: { directory: deps.directory } } : {}),
|
|
698
|
+
}),
|
|
699
|
+
)
|
|
700
|
+
} catch (e) {
|
|
701
|
+
log("metadata write failed (spool still covers reconnects):", (e as Error)?.message || e)
|
|
702
|
+
}
|
|
703
|
+
})
|
|
663
704
|
}
|
|
664
705
|
|
|
665
706
|
/** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
|
|
@@ -770,12 +811,51 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
770
811
|
const spoolSave = (st: SessionState, r: RemoteState) => {
|
|
771
812
|
try {
|
|
772
813
|
writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }))
|
|
773
|
-
|
|
814
|
+
// the queue line IS the wire frame when prebuilt (single
|
|
815
|
+
// serialization end-to-end); a pending frame without wire (legacy
|
|
816
|
+
// spool load) serializes here
|
|
817
|
+
writeFileSync(queuePath(st, r.url), r.pending.map((f) => f.wire ?? JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""))
|
|
818
|
+
// the migrated content now lives in the new queue (or was acked):
|
|
819
|
+
// retire the single-server-era files so they don't accumulate forever
|
|
820
|
+
rmSync(legacyMetaPath(st), { force: true })
|
|
821
|
+
rmSync(legacyQueuePath(st), { force: true })
|
|
774
822
|
} catch (e) {
|
|
775
823
|
logError("spool save failed", (e as Error)?.message || e)
|
|
776
824
|
}
|
|
777
825
|
}
|
|
826
|
+
// hot path: appending one pending frame must not re-serialize the whole
|
|
827
|
+
// queue (O(n^2) during backfill bursts) — append the line instead
|
|
828
|
+
const spoolAppend = (st: SessionState, r: RemoteState, f: { items: unknown[]; status: string; seq?: number; wire?: string }) => {
|
|
829
|
+
try {
|
|
830
|
+
appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n")
|
|
831
|
+
} catch (e) {
|
|
832
|
+
// the frame lives ONLY in memory now — flag the remote so the next
|
|
833
|
+
// spoolLoad does NOT replace in-memory pending with the shorter
|
|
834
|
+
// disk queue (silent loss otherwise; live-caught class in review)
|
|
835
|
+
r.spoolAppendFailed = true
|
|
836
|
+
logError("spool append failed", (e as Error)?.message || e)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/** Debounced ack-path spool rewrite: one ack per chunk during a
|
|
840
|
+
* multi-chunk replay each rewrote the WHOLE queue file (O(n^2) —
|
|
841
|
+
* ~160MB of sync disk churn on a 25-chunk outage recovery). A crash
|
|
842
|
+
* inside the 300ms window only loses "these frames were acked" — the
|
|
843
|
+
* next connect replays idempotent keyed upserts, which the protocol
|
|
844
|
+
* already tolerates. init-ok keeps the immediate save (binding/
|
|
845
|
+
* recovery info). */
|
|
846
|
+
const scheduleSpoolSave = (st: SessionState, r: RemoteState) => {
|
|
847
|
+
if (r.spoolSaveTimer) return
|
|
848
|
+
r.spoolSaveTimer = setTimeout(() => {
|
|
849
|
+
r.spoolSaveTimer = null
|
|
850
|
+
if (!st.closed) spoolSave(st, r)
|
|
851
|
+
}, 300)
|
|
852
|
+
}
|
|
778
853
|
const spoolLoad = (st: SessionState, r: RemoteState) => {
|
|
854
|
+
// meta first (share binding recovery), in its OWN try: a missing meta
|
|
855
|
+
// file is normal (it's only written after the first successful
|
|
856
|
+
// handshake) and must NOT touch pending (live-caught class: the old
|
|
857
|
+
// blanket catch wiped in-memory pending AND skipped the queue read,
|
|
858
|
+
// so everything queued before the first successful connect was lost).
|
|
779
859
|
try {
|
|
780
860
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
|
|
781
861
|
// metadata binding (db) is authoritative when both exist and disagree
|
|
@@ -784,15 +864,40 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
784
864
|
r.secret = meta.secret
|
|
785
865
|
r.ackedRev = meta.ackedRev ?? 0
|
|
786
866
|
}
|
|
867
|
+
} catch {}
|
|
868
|
+
try {
|
|
787
869
|
if (existsSync(queuePath(st, r.url))) {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
870
|
+
// append-only spool: a crash mid-append can tear the LAST line —
|
|
871
|
+
// keep the valid prefix instead of dropping the whole queue.
|
|
872
|
+
// The disk queue is the authority: every in-memory frame was
|
|
873
|
+
// appended before it was tracked, so replacing pending with the
|
|
874
|
+
// parsed queue loses nothing. A FAILED read keeps in-memory
|
|
875
|
+
// pending untouched.
|
|
876
|
+
const disk: Array<{ items: unknown[]; status: string; seq?: number }> = []
|
|
877
|
+
for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
|
|
878
|
+
if (!line.trim()) continue
|
|
879
|
+
try {
|
|
880
|
+
disk.push(JSON.parse(line))
|
|
881
|
+
} catch {
|
|
882
|
+
log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`)
|
|
883
|
+
break
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
// seq hygiene: re-stamp pre-seq spool lines (an ack without seq
|
|
887
|
+
// would otherwise clear ALL pending — the H2 wipe, once per
|
|
888
|
+
// upgrade) and seed the counter past every restored seq (a
|
|
889
|
+
// restart resetting to 0 would collide new frames with old ones)
|
|
890
|
+
for (const f of disk) {
|
|
891
|
+
if (typeof f.seq !== "number") f.seq = ++nextSeq
|
|
892
|
+
else if (f.seq >= nextSeq) nextSeq = f.seq + 1
|
|
893
|
+
}
|
|
894
|
+
// a failed append means the disk queue can be SHORTER than
|
|
895
|
+
// memory — the disk is only authoritative when every append
|
|
896
|
+
// landed (otherwise the replace would drop the memory-only frame)
|
|
897
|
+
if (!r.spoolAppendFailed) r.pending = disk
|
|
898
|
+
else r.spoolAppendFailed = false
|
|
792
899
|
}
|
|
793
|
-
} catch {
|
|
794
|
-
r.pending = []
|
|
795
|
-
}
|
|
900
|
+
} catch {}
|
|
796
901
|
// single-server-era spool (no url suffix): belongs to the first target
|
|
797
902
|
if (!r.shareId && targets[0]?.url === r.url) {
|
|
798
903
|
try {
|
|
@@ -814,12 +919,27 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
814
919
|
}
|
|
815
920
|
const spoolClear = (st: SessionState, r: RemoteState) => {
|
|
816
921
|
try {
|
|
922
|
+
if (r.spoolSaveTimer) {
|
|
923
|
+
clearTimeout(r.spoolSaveTimer)
|
|
924
|
+
r.spoolSaveTimer = null
|
|
925
|
+
}
|
|
817
926
|
rmSync(metaPath(st, r.url), { force: true })
|
|
818
927
|
rmSync(queuePath(st, r.url), { force: true })
|
|
928
|
+
// single-server-era files die with the session too
|
|
929
|
+
rmSync(legacyMetaPath(st), { force: true })
|
|
930
|
+
rmSync(legacyQueuePath(st), { force: true })
|
|
819
931
|
} catch {}
|
|
820
932
|
}
|
|
821
933
|
|
|
822
934
|
// ---- websocket transport (per remote) ----------------------------------
|
|
935
|
+
/** Monotonic client frame sequence: every chunk-frame gets one; the
|
|
936
|
+
* server echoes it in the ack, and the plugin clears exactly that
|
|
937
|
+
* chunk from pending. (Live-caught class: one logical frame split into
|
|
938
|
+
* N chunks — the worker acks EACH chunk, and a blanket "pending = []"
|
|
939
|
+
* on the first ack erased chunks 2..N; a socket death right there lost
|
|
940
|
+
* them permanently.) */
|
|
941
|
+
let nextSeq = 0
|
|
942
|
+
|
|
823
943
|
const wsSend = (r: RemoteState, obj: unknown) => {
|
|
824
944
|
if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${(obj as { op?: string })?.op} (ready=${r.wsReady})`)
|
|
825
945
|
try {
|
|
@@ -830,15 +950,66 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
830
950
|
return true
|
|
831
951
|
}
|
|
832
952
|
|
|
833
|
-
/** Sync frames are chunked
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
953
|
+
/** Sync frames are chunked by BOTH item count and serialized bytes.
|
|
954
|
+
* Count-only chunking could pack 200 large tool outputs into a frame
|
|
955
|
+
* that exceeds the server's WS receive cap (CF 32 MiB, Bun 16 MB
|
|
956
|
+
* default) and die wholesale. Budget keeps every frame cheap to apply
|
|
957
|
+
* and rebroadcast; worker apply is keyed-idempotent, so chunks just
|
|
958
|
+
* bump rev. An item larger than the whole budget rides alone in its
|
|
959
|
+
* own frame (the worker fits oversize items to its storage cap).
|
|
960
|
+
* Status rides each chunk; last write wins.
|
|
961
|
+
* Returns each chunk's PREBUILT items JSON: the wire frame and the
|
|
962
|
+
* spool line are derived from it by concatenation — every item used
|
|
963
|
+
* to be serialized 3-5x per remote per flush (chunk measure, spool
|
|
964
|
+
* append, ws send, sendSync re-chunk). */
|
|
965
|
+
const chunkedFrames = (items: unknown[], maxItems: number, maxBytes: number): Array<{ items: unknown[]; json: string }> => {
|
|
966
|
+
const chunks: Array<{ items: unknown[]; json: string }> = []
|
|
967
|
+
let cur: unknown[] = []
|
|
968
|
+
let curJson: string[] = []
|
|
969
|
+
let curBytes = 0
|
|
970
|
+
for (const it of items) {
|
|
971
|
+
const j = JSON.stringify(it) ?? "null"
|
|
972
|
+
if (cur.length && (cur.length >= maxItems || curBytes + j.length > maxBytes)) {
|
|
973
|
+
chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" })
|
|
974
|
+
cur = []
|
|
975
|
+
curJson = []
|
|
976
|
+
curBytes = 0
|
|
977
|
+
}
|
|
978
|
+
cur.push(it)
|
|
979
|
+
curJson.push(j)
|
|
980
|
+
curBytes += j.length
|
|
981
|
+
}
|
|
982
|
+
if (cur.length) chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" })
|
|
983
|
+
return chunks
|
|
984
|
+
}
|
|
985
|
+
/** legacy chunker (count/byte split without prebuilt JSON) — replay
|
|
986
|
+
* paths that only need the split */
|
|
987
|
+
const chunkItems = (items: unknown[], maxItems: number, maxBytes: number): unknown[][] =>
|
|
988
|
+
chunkedFrames(items, maxItems, maxBytes).map((c) => c.items)
|
|
989
|
+
/** the wire sync frame, built by concatenation over the prebuilt items
|
|
990
|
+
* JSON — byte-identical to JSON.stringify({op,items,status,seq}) */
|
|
991
|
+
const syncFrameStr = (itemsJson: string, status: string, seq: number | undefined) =>
|
|
992
|
+
`{"op":"sync","items":${itemsJson},"status":${JSON.stringify(status)}${seq !== undefined ? `,"seq":${seq}` : ""}}`
|
|
993
|
+
const wsSendRaw = (r: RemoteState, s: string) => {
|
|
994
|
+
if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send raw (${Math.round(s.length / 1024)}KB, ready=${r.wsReady})`)
|
|
995
|
+
try {
|
|
996
|
+
r.ws?.send(s)
|
|
997
|
+
} catch {
|
|
998
|
+
return false
|
|
999
|
+
}
|
|
1000
|
+
return true
|
|
1001
|
+
}
|
|
1002
|
+
const sendSync = (r: RemoteState, f: { items: unknown[]; status: string; seq?: number; wire?: string }) => {
|
|
1003
|
+
if (!f.items.length) return wsSend(r, { op: "sync", items: [], status: f.status, seq: f.seq })
|
|
1004
|
+
// prebuilt wire (the hot flush path): one serialization total
|
|
1005
|
+
if (f.wire) return wsSendRaw(r, f.wire)
|
|
1006
|
+
// replayed/legacy frames: chunk-shaped by construction — only an
|
|
1007
|
+
// oversize one (a pre-chunking spool line) needs re-splitting
|
|
1008
|
+
const itemsJson = JSON.stringify(f.items)
|
|
1009
|
+
if (f.items.length <= 200 && itemsJson.length <= 512 * 1024) {
|
|
1010
|
+
return wsSendRaw(r, syncFrameStr(itemsJson, f.status, f.seq))
|
|
841
1011
|
}
|
|
1012
|
+
for (const c of chunkedFrames(f.items, 200, 512 * 1024)) wsSendRaw(r, syncFrameStr(c.json, f.status, f.seq))
|
|
842
1013
|
return true
|
|
843
1014
|
}
|
|
844
1015
|
|
|
@@ -1017,7 +1188,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1017
1188
|
logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`)
|
|
1018
1189
|
return
|
|
1019
1190
|
}
|
|
1020
|
-
|
|
1191
|
+
// frame auth: when the server advertises it, keep the token off the
|
|
1192
|
+
// URL (query strings land in proxy/access logs). The auth frame
|
|
1193
|
+
// rides the socket in order — the server sets its identity before
|
|
1194
|
+
// our init arrives. Old servers fall back to ?token=.
|
|
1195
|
+
const useFrameAuth = !!token && frameAuthUrls.has(r.url)
|
|
1196
|
+
const ws = new WebSocket(`${wsUrl(r.url)}/api/ws${useFrameAuth ? "" : `?token=${encodeURIComponent(token ?? "")}`}`)
|
|
1021
1197
|
r.ws = ws
|
|
1022
1198
|
await new Promise((resolve, reject) => {
|
|
1023
1199
|
const t = setTimeout(() => reject(new Error("ws open timeout")), 15000)
|
|
@@ -1055,6 +1231,31 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1055
1231
|
if (!st.closed) scheduleReconnect(st, r)
|
|
1056
1232
|
})
|
|
1057
1233
|
|
|
1234
|
+
if (useFrameAuth) {
|
|
1235
|
+
wsSend(r, { op: "auth", token })
|
|
1236
|
+
// the auth and init frames can interleave in the worker's
|
|
1237
|
+
// concurrent webSocketMessage invocations — wait for the authed
|
|
1238
|
+
// ack before authenticating anything (timeout -> fall back to a
|
|
1239
|
+
// query-token reconnect)
|
|
1240
|
+
const authed = await new Promise<boolean>((resolve) => {
|
|
1241
|
+
const t = setTimeout(() => resolve(false), 3000)
|
|
1242
|
+
r.authWaiter = () => {
|
|
1243
|
+
clearTimeout(t)
|
|
1244
|
+
resolve(true)
|
|
1245
|
+
}
|
|
1246
|
+
})
|
|
1247
|
+
r.authWaiter = undefined
|
|
1248
|
+
if (!authed) {
|
|
1249
|
+
logError(`ws ${r.url.slice(0, 24)}: frame-auth ack timeout - reconnecting with query token`)
|
|
1250
|
+
frameAuthUrls.delete(r.url)
|
|
1251
|
+
try {
|
|
1252
|
+
ws.close(4000, "auth timeout")
|
|
1253
|
+
} catch {}
|
|
1254
|
+
if (!st.closed) scheduleReconnect(st, r)
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1058
1259
|
if (r.initWasAttach) {
|
|
1059
1260
|
wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } })
|
|
1060
1261
|
} else {
|
|
@@ -1084,7 +1285,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1084
1285
|
|
|
1085
1286
|
const scheduleReconnect = (st: SessionState, r: RemoteState) => {
|
|
1086
1287
|
if (st.closed || r.reconnectTimer) return
|
|
1087
|
-
|
|
1288
|
+
// jitter (±20%) so fan-out remotes and multiple sessions never sync
|
|
1289
|
+
// their retry storms onto the same second
|
|
1290
|
+
const wait = Math.max(250, Math.round(r.backoff * (0.8 + Math.random() * 0.4)))
|
|
1088
1291
|
r.backoff = Math.min(r.backoff * 2, 60000)
|
|
1089
1292
|
r.reconnectTimer = setTimeout(() => {
|
|
1090
1293
|
r.reconnectTimer = null
|
|
@@ -1170,6 +1373,15 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1170
1373
|
}
|
|
1171
1374
|
|
|
1172
1375
|
const handleFrame = async (st: SessionState, r: RemoteState, frame: Record<string, unknown>) => {
|
|
1376
|
+
if (frame.op === "authed") {
|
|
1377
|
+
// frame-auth handshake ack: release the init (the auth and init
|
|
1378
|
+
// frames can interleave server-side — webSocketMessage invocations
|
|
1379
|
+
// run concurrently — so the init must WAIT for this, else it
|
|
1380
|
+
// authenticates as anonymous/tokenless)
|
|
1381
|
+
r.authWaiter?.()
|
|
1382
|
+
r.authWaiter = undefined
|
|
1383
|
+
return
|
|
1384
|
+
}
|
|
1173
1385
|
if (frame.op === "init") {
|
|
1174
1386
|
if (!frame.ok) {
|
|
1175
1387
|
const err = String(frame.error ?? "")
|
|
@@ -1223,6 +1435,19 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1223
1435
|
// bad base (mismatch protocol). Content REPLAYS to the new share —
|
|
1224
1436
|
// pending is never dropped on a reset (live-caught: the recreate
|
|
1225
1437
|
// husk had the title but zero messages).
|
|
1438
|
+
if (/limit|too many|rate/i.test(err)) {
|
|
1439
|
+
// quota/429 ("max shares reached", "daily create limit", the
|
|
1440
|
+
// anonymous caps): an immediate re-init on the same socket is a
|
|
1441
|
+
// hot loop against the server — back off through the normal
|
|
1442
|
+
// reconnect cadence instead
|
|
1443
|
+
logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff")
|
|
1444
|
+
r.connecting = false
|
|
1445
|
+
try {
|
|
1446
|
+
r.ws?.close(4000, "rate limited")
|
|
1447
|
+
} catch {}
|
|
1448
|
+
if (!st.closed) scheduleReconnect(st, r)
|
|
1449
|
+
return
|
|
1450
|
+
}
|
|
1226
1451
|
logError(`${r.url}: init refused:`, frame.error, "- creating fresh share")
|
|
1227
1452
|
deleteAbandoned(r)
|
|
1228
1453
|
r.shareId = null
|
|
@@ -1253,11 +1478,23 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1253
1478
|
r.connecting = false
|
|
1254
1479
|
r.initWasAttach = false
|
|
1255
1480
|
r.collided = null
|
|
1481
|
+
// a successful handshake proves write access: an admin unfreeze
|
|
1482
|
+
// revives the remote from here (frozenRemote is otherwise sticky)
|
|
1483
|
+
if (r.frozenRemote && !frame.error) r.frozenRemote = false
|
|
1256
1484
|
if (r.initTimer) {
|
|
1257
1485
|
clearTimeout(r.initTimer)
|
|
1258
1486
|
r.initTimer = null
|
|
1259
1487
|
}
|
|
1260
|
-
|
|
1488
|
+
// backoff reset is DEFERRED until the socket proves stable: a
|
|
1489
|
+
// server that accepts init then immediately drops would otherwise
|
|
1490
|
+
// reconnect once per second forever (~1 cycle/s per session-server
|
|
1491
|
+
// pair, live-caught in the optimization audit). 30s alive (socket
|
|
1492
|
+
// still ready, no reconnect pending) or a second acked sync.
|
|
1493
|
+
if (r.backoffResetTimer) clearTimeout(r.backoffResetTimer)
|
|
1494
|
+
r.backoffResetTimer = setTimeout(() => {
|
|
1495
|
+
r.backoffResetTimer = null
|
|
1496
|
+
if (r.wsReady && !r.reconnectTimer) r.backoff = 1000
|
|
1497
|
+
}, 30000)
|
|
1261
1498
|
st.everConnected = true
|
|
1262
1499
|
if (frame.policy) applyPolicy(frame.policy, r.url)
|
|
1263
1500
|
r.intentionalClose = false
|
|
@@ -1279,19 +1516,67 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1279
1516
|
if (wasFanoutCreate) {
|
|
1280
1517
|
// a server joined an ALREADY-live session (fan-out add, or a
|
|
1281
1518
|
// post-mismatch recreate): its fresh share needs the full history —
|
|
1282
|
-
// backfill is keyed-idempotent, other servers just re-upsert
|
|
1519
|
+
// backfill is keyed-idempotent, other servers just re-upsert.
|
|
1520
|
+
// The enqueue dedupe must step aside: those hashes say "already
|
|
1521
|
+
// entered the machinery", but the NEW remote never received any
|
|
1522
|
+
// of it (live-caught by F3: the join's backfill was fully
|
|
1523
|
+
// deduped away and the joined share stayed empty).
|
|
1524
|
+
st.sentHash.clear()
|
|
1283
1525
|
chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", (e as Error)?.message || e))
|
|
1284
1526
|
}
|
|
1285
1527
|
return
|
|
1286
1528
|
}
|
|
1287
1529
|
if (frame.op === "ack") {
|
|
1288
1530
|
r.ackedRev = (frame.rev as number) ?? r.ackedRev
|
|
1289
|
-
|
|
1290
|
-
|
|
1531
|
+
// a second acked sync also proves the socket stable — release the
|
|
1532
|
+
// backoff reset timer early (complements the 30s-alive timer)
|
|
1533
|
+
if (r.backoffResetTimer) {
|
|
1534
|
+
clearTimeout(r.backoffResetTimer)
|
|
1535
|
+
r.backoffResetTimer = null
|
|
1536
|
+
r.backoff = 1000
|
|
1537
|
+
}
|
|
1538
|
+
// seq-scoped retirement: an ack identifies the chunk it applied.
|
|
1539
|
+
// An ack WITHOUT seq = old server: legacy clear-all (correct there —
|
|
1540
|
+
// old servers acked the whole pending batch together).
|
|
1541
|
+
if (typeof frame.seq === "number") {
|
|
1542
|
+
r.pending = r.pending.filter((p) => p.seq !== frame.seq)
|
|
1543
|
+
} else {
|
|
1544
|
+
r.pending = []
|
|
1545
|
+
}
|
|
1546
|
+
scheduleSpoolSave(st, r)
|
|
1291
1547
|
scheduleBindingWrite(st)
|
|
1292
1548
|
return
|
|
1293
1549
|
}
|
|
1294
1550
|
if (frame.op === "nack" || frame.op === "error") {
|
|
1551
|
+
const errText = String(frame.error ?? "")
|
|
1552
|
+
if (frame.op === "nack") {
|
|
1553
|
+
// classify: the share dying server-side under a live socket
|
|
1554
|
+
// (sweeper TTL, admin delete, purge) surfaces as nacks — the
|
|
1555
|
+
// socket is "healthy" so nothing else recovers it. Same reset as
|
|
1556
|
+
// the init-refused path: fresh create + content replay.
|
|
1557
|
+
if (/not found|gone/i.test(errText)) {
|
|
1558
|
+
logError(`${r.url}: share vanished server-side (nack: ${errText}) - recreating`)
|
|
1559
|
+
deleteAbandoned(r)
|
|
1560
|
+
r.shareId = null
|
|
1561
|
+
r.secret = null
|
|
1562
|
+
r.ackedRev = 0
|
|
1563
|
+
try {
|
|
1564
|
+
r.ws?.close(4000, "share gone")
|
|
1565
|
+
} catch {}
|
|
1566
|
+
if (!st.closed) queueConnect(st, r)
|
|
1567
|
+
return
|
|
1568
|
+
}
|
|
1569
|
+
if (/frozen/i.test(errText)) {
|
|
1570
|
+
// owner frozen mid-session: every write is refused. Stop
|
|
1571
|
+
// spooling NEW frames for this remote (unbounded growth) but
|
|
1572
|
+
// KEEP what's already queued — an unfreeze revives the remote
|
|
1573
|
+
// on the next successful handshake (init-ok clears the flag)
|
|
1574
|
+
// and the backlog replays from there.
|
|
1575
|
+
r.frozenRemote = true
|
|
1576
|
+
logError(`${r.url}: share frozen by the hoster - edits refused; contact the server admin`)
|
|
1577
|
+
return
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1295
1580
|
logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200))
|
|
1296
1581
|
// the control socket lost its attach state (worker-side eviction of
|
|
1297
1582
|
// the control DO): force a reconnect — init attach replays pending
|
|
@@ -1323,6 +1608,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1323
1608
|
|
|
1324
1609
|
// ---- flush -------------------------------------------------------------
|
|
1325
1610
|
const frameFrom = (st: SessionState) => {
|
|
1611
|
+
// stamps run at frame time: the whole family's data is indexed by now
|
|
1612
|
+
// (child prompts are created during the task call, well inside the
|
|
1613
|
+
// flush debounce), and any re-enqueued message lands in this frame
|
|
1614
|
+
stampsPass(st)
|
|
1326
1615
|
const items = Array.from(st.queue.values())
|
|
1327
1616
|
st.queue.clear()
|
|
1328
1617
|
const statusChanged = st.status !== st.lastSentStatus
|
|
@@ -1344,51 +1633,115 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1344
1633
|
if (!f) return
|
|
1345
1634
|
// fan out to every remote; each carries its own pending copy and acks
|
|
1346
1635
|
// independently. Remotes mid-handshake or auth-blocked just buffer.
|
|
1636
|
+
// PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
|
|
1637
|
+
// chunk-frames so a partial-ack can never erase unsent chunks
|
|
1638
|
+
// (live-caught class: chunk-1 ack + socket death erased chunks 2..N).
|
|
1639
|
+
const chunks: Array<{ items: unknown[]; json: string }> = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : []
|
|
1347
1640
|
let httpWork: Promise<unknown> | undefined
|
|
1348
1641
|
for (const r of st.remotes.values()) {
|
|
1642
|
+
if (r.frozenRemote) continue // refused server-side; stop spooling
|
|
1643
|
+
const fresh: Array<{ items: unknown[]; status: string; seq: number; wire?: string }> = []
|
|
1644
|
+
if (chunks.length) {
|
|
1645
|
+
for (const c of chunks) {
|
|
1646
|
+
const seq = ++nextSeq
|
|
1647
|
+
const cf = { items: c.items, status: f.status, seq, wire: syncFrameStr(c.json, f.status, seq) }
|
|
1648
|
+
fresh.push(cf)
|
|
1649
|
+
r.pending.push(cf)
|
|
1650
|
+
spoolAppend(st, r, cf)
|
|
1651
|
+
}
|
|
1652
|
+
} else {
|
|
1653
|
+
// status-only frame (empty items): one chunk-less frame
|
|
1654
|
+
const seq = ++nextSeq
|
|
1655
|
+
const cf = { items: [] as unknown[], status: f.status, seq }
|
|
1656
|
+
fresh.push(cf)
|
|
1657
|
+
r.pending.push(cf)
|
|
1658
|
+
spoolAppend(st, r, cf)
|
|
1659
|
+
}
|
|
1349
1660
|
// never send syncs before the init ack: the worker's per-socket
|
|
1350
1661
|
// ControlDO has no attach until OUR init completes, and a racing
|
|
1351
1662
|
// sync dies as "unknown op" (live-caught: post-drop reconnects
|
|
1352
1663
|
// lost whole turns). The init-ok handler flushes pending itself.
|
|
1353
|
-
r.pending.push(f)
|
|
1354
|
-
spoolSave(st, r)
|
|
1355
1664
|
if (r.ws && !r.wsReady) continue
|
|
1356
1665
|
if (r.wsReady) {
|
|
1357
|
-
sendSync(r,
|
|
1666
|
+
for (const cf of fresh) sendSync(r, cf)
|
|
1358
1667
|
continue
|
|
1359
1668
|
}
|
|
1360
1669
|
const viaHttp = flushViaHttp(st, r)
|
|
1361
1670
|
if (viaHttp) httpWork = (httpWork ?? Promise.resolve()).then(() => viaHttp)
|
|
1362
1671
|
}
|
|
1363
1672
|
if (st.remotes.size) st.lastFlushAt = Date.now()
|
|
1673
|
+
// the HTTP work runs detached from the flush chain (one dead server
|
|
1674
|
+
// must not stall the fan-out); dispose awaits its own final flush
|
|
1675
|
+
if (httpWork) void httpWork.catch((e) => logError("http flush error", (e as Error)?.message || e))
|
|
1364
1676
|
return httpWork
|
|
1365
1677
|
}
|
|
1366
1678
|
|
|
1679
|
+
/** Client-side key derivation mirroring the server's itemKey — used only
|
|
1680
|
+
* for deduping the HTTP fallback batch (keyed upserts: the LAST item
|
|
1681
|
+
* for a key wins, matching apply() semantics). */
|
|
1682
|
+
const httpDedupeKey = (it: unknown): string | null => {
|
|
1683
|
+
if (!it || typeof it !== "object") return null
|
|
1684
|
+
const o = it as { type?: string; data?: { id?: string; messageID?: string; sessionID?: string }; sessionID?: string }
|
|
1685
|
+
switch (o.type) {
|
|
1686
|
+
case "session": return "session/" + (o.data?.id ?? "")
|
|
1687
|
+
case "session_diff": return "session_diff/" + (o.sessionID ?? o.data?.sessionID ?? "")
|
|
1688
|
+
case "message": return "message/" + (o.data?.id ?? "")
|
|
1689
|
+
case "part": return "part/" + (o.data?.messageID ?? "") + "/" + (o.data?.id ?? "")
|
|
1690
|
+
case "model": return "model/" + (o.sessionID ?? o.data?.sessionID ?? "")
|
|
1691
|
+
case "prefs": return "prefs"
|
|
1692
|
+
default: return null
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1367
1696
|
const flushViaHttp = async (st: SessionState, r: RemoteState) => {
|
|
1368
1697
|
if (!r.shareId || !r.pending.length) return
|
|
1369
1698
|
// ALL pending frames, merged (keyed upserts — later wins). Sending
|
|
1370
1699
|
// only the last frame dropped every earlier batch's items whenever
|
|
1371
1700
|
// the HTTP fallback carried more than one (live-caught class:
|
|
1372
|
-
// content loss on reconnect paths).
|
|
1373
|
-
|
|
1701
|
+
// content loss on reconnect paths). DEDUPE BY KEY before chunking:
|
|
1702
|
+
// a streaming message appearing in k pending frames shipped k
|
|
1703
|
+
// copies (up to 25x on a replay burst) — every duplicate was also a
|
|
1704
|
+
// server row-write + a rebroadcast frame.
|
|
1705
|
+
const byKey = new Map<string, unknown>()
|
|
1706
|
+
const unkeyed: unknown[] = []
|
|
1374
1707
|
let status: string | undefined
|
|
1708
|
+
const sentSeqs = new Set<number>()
|
|
1375
1709
|
for (const f of r.pending) {
|
|
1376
|
-
for (const it of f.items as unknown[])
|
|
1710
|
+
for (const it of f.items as unknown[]) {
|
|
1711
|
+
const k = httpDedupeKey(it)
|
|
1712
|
+
if (k) byKey.set(k, it)
|
|
1713
|
+
else unkeyed.push(it)
|
|
1714
|
+
}
|
|
1377
1715
|
if (f.status) status = f.status
|
|
1716
|
+
if (typeof f.seq === "number") sentSeqs.add(f.seq)
|
|
1378
1717
|
}
|
|
1718
|
+
const items = [...byKey.values(), ...unkeyed]
|
|
1379
1719
|
const cred = credOf(r.url)
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1720
|
+
// chunked like WS frames: one unbounded body is a whole-backlog
|
|
1721
|
+
// bomb after a mid-backfill socket drop (backfill = full history).
|
|
1722
|
+
// Failure stops the loop with pending intact — replaying already-
|
|
1723
|
+
// applied chunks is idempotent.
|
|
1724
|
+
// status-only frames (items empty, e.g. the terminal completed
|
|
1725
|
+
// flush in HTTP-only mode) still need ONE request or the status
|
|
1726
|
+
// never lands (the share stays "live" forever)
|
|
1727
|
+
const chunks = items.length ? chunkItems(items, 500, 4 * 1024 * 1024) : [[]]
|
|
1728
|
+
for (const chunk of chunks) {
|
|
1729
|
+
const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
|
|
1730
|
+
method: "POST",
|
|
1731
|
+
headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
|
|
1732
|
+
body: JSON.stringify({ secret: r.secret ?? "", data: chunk, status }),
|
|
1733
|
+
signal: AbortSignal.timeout(15000),
|
|
1734
|
+
}).catch(() => null)
|
|
1735
|
+
if (!res || !res.ok) return
|
|
1387
1736
|
const body = (await res.json().catch(() => ({}))) as { rev?: number }
|
|
1388
1737
|
r.ackedRev = body.rev ?? r.ackedRev
|
|
1389
|
-
r.pending = []
|
|
1390
|
-
spoolSave(st, r)
|
|
1391
1738
|
}
|
|
1739
|
+
// retire EXACTLY what this run sent: a concurrent flush may have
|
|
1740
|
+
// queued new frames while our fetches were in flight (the HTTP work
|
|
1741
|
+
// is deliberately detached from the flush chain) — a blanket clear
|
|
1742
|
+
// would erase them before anything transmitted them
|
|
1743
|
+
r.pending = r.pending.filter((p) => !sentSeqs.has(p.seq as number))
|
|
1744
|
+
scheduleSpoolSave(st, r)
|
|
1392
1745
|
}
|
|
1393
1746
|
|
|
1394
1747
|
const scheduleFlush = (st: SessionState) => {
|
|
@@ -1403,19 +1756,350 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1403
1756
|
st.timer = setTimeout(() => {
|
|
1404
1757
|
st.timer = null
|
|
1405
1758
|
if (debugEvents) log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`)
|
|
1406
|
-
|
|
1759
|
+
// NOTE: the flush's HTTP-fallback promise is deliberately NOT
|
|
1760
|
+
// chained — one black-holing server (15s timeout per chunk) must
|
|
1761
|
+
// not stall every other remote's flushes behind it. Dispose awaits
|
|
1762
|
+
// its own final flush explicitly.
|
|
1763
|
+
chain = chain
|
|
1764
|
+
.then(() => {
|
|
1765
|
+
flush(st)
|
|
1766
|
+
})
|
|
1767
|
+
.catch((e) => logError("flush error", (e as Error)?.message || e))
|
|
1407
1768
|
}, wait)
|
|
1408
1769
|
}
|
|
1409
1770
|
|
|
1410
1771
|
const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
|
|
1411
1772
|
if (!st || !entry) return
|
|
1773
|
+
// content dedupe at the machinery boundary: an identical re-feed has
|
|
1774
|
+
// either entered the queue already or been flushed into every
|
|
1775
|
+
// remote's pending/spool (the per-remote delivery machinery owns it
|
|
1776
|
+
// from there) — re-sending it bumps rev, rewrites storage and
|
|
1777
|
+
// re-folds every viewer for nothing. This is the engine-side home
|
|
1778
|
+
// the v2-adapter dedupe lacked (an adapter-side cache poisoned while
|
|
1779
|
+
// dormant and skipped the flag-time content — live-caught).
|
|
1780
|
+
let h: string
|
|
1781
|
+
try {
|
|
1782
|
+
h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex")
|
|
1783
|
+
} catch {
|
|
1784
|
+
h = ""
|
|
1785
|
+
}
|
|
1786
|
+
if (h && st.sentHash.get(entry.key) === h) {
|
|
1787
|
+
if (debugEvents) log(`enqueue dedupe ${entry.item.type} ${entry.key.slice(0, 40)}`)
|
|
1788
|
+
return
|
|
1789
|
+
}
|
|
1790
|
+
if (h) st.sentHash.set(entry.key, h)
|
|
1412
1791
|
st.queue.set(entry.key, entry.item)
|
|
1792
|
+
indexMessage(entry)
|
|
1413
1793
|
if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
|
|
1414
1794
|
touch(st)
|
|
1415
1795
|
ensureConnected(st)
|
|
1416
1796
|
scheduleFlush(st)
|
|
1417
1797
|
}
|
|
1418
1798
|
|
|
1799
|
+
// ---- authoritative stamps ----------------------------------------------
|
|
1800
|
+
// The viewer renders stamped facts and never re-derives them (owner
|
|
1801
|
+
// directive). Both stamps are computed at frame time across the whole
|
|
1802
|
+
// share family - the engine sees every session of the family regardless
|
|
1803
|
+
// of host generation, so this is the only place with the full picture.
|
|
1804
|
+
|
|
1805
|
+
type IndexedMessage = {
|
|
1806
|
+
sid: string
|
|
1807
|
+
role: string
|
|
1808
|
+
agent?: string
|
|
1809
|
+
created: number
|
|
1810
|
+
data: Record<string, unknown>
|
|
1811
|
+
}
|
|
1812
|
+
const msgIndex = new Map<string, IndexedMessage>() // `${sid}:${messageID}`
|
|
1813
|
+
/** message ids tombstoned this run: stampsPass must never re-upsert an
|
|
1814
|
+
* undone message (live-caught class: dropMergedMessage removed the
|
|
1815
|
+
* merged entry but msgIndex kept the corpse; a neighboring-segment
|
|
1816
|
+
* change recomputed its stamps and reEnqueueMessage resurrected the
|
|
1817
|
+
* deleted turn server-side). */
|
|
1818
|
+
const tombstoned = new Set<string>()
|
|
1819
|
+
|
|
1820
|
+
const indexMessage = (entry: ItemEntry) => {
|
|
1821
|
+
if (entry.item.type !== "message") return
|
|
1822
|
+
const d = entry.item.data as Record<string, any> | undefined
|
|
1823
|
+
if (!d || typeof d.id !== "string") return
|
|
1824
|
+
const role = String(d.type ?? "")
|
|
1825
|
+
if (role !== "user" && role !== "assistant") return
|
|
1826
|
+
const key = `${entry.item.sessionID}:${d.id}`
|
|
1827
|
+
const prev = msgIndex.get(key)
|
|
1828
|
+
msgIndex.set(key, {
|
|
1829
|
+
sid: String(entry.item.sessionID ?? ""),
|
|
1830
|
+
role: role as string,
|
|
1831
|
+
agent:
|
|
1832
|
+
role === "assistant"
|
|
1833
|
+
? typeof d.agent === "string"
|
|
1834
|
+
? d.agent
|
|
1835
|
+
: prev?.agent
|
|
1836
|
+
: typeof d.metadata?.agent === "string"
|
|
1837
|
+
? d.metadata.agent
|
|
1838
|
+
: prev?.agent,
|
|
1839
|
+
created: Number(d.time?.created ?? prev?.created ?? 0),
|
|
1840
|
+
data: d as Record<string, unknown>,
|
|
1841
|
+
})
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
const sessionStateOf = (sid: string): SessionState | undefined =>
|
|
1845
|
+
sessions.get(childToParent.get(sid) ?? sid)
|
|
1846
|
+
|
|
1847
|
+
const reEnqueueMessage = (m: IndexedMessage) => {
|
|
1848
|
+
// tombstone guard: an undone message stays dead even if a stamp pass
|
|
1849
|
+
// recomputes its segment metadata
|
|
1850
|
+
if (tombstoned.has(`${m.sid}:${m.data.id}`)) return
|
|
1851
|
+
const st = sessionStateOf(m.sid)
|
|
1852
|
+
if (st) enqueue(st, itemFor("message", m.data, m.sid))
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/** segment agent for a user prompt: the prompt's own recorded agent (v1
|
|
1856
|
+
* hosts stamp it on every user message), else the first assistant
|
|
1857
|
+
* response inside the segment (v2 hosts), else carry-forward. */
|
|
1858
|
+
const segmentAgent = (ordered: IndexedMessage[], at: number): string | undefined => {
|
|
1859
|
+
const own = ordered[at]?.agent
|
|
1860
|
+
if (own) return own
|
|
1861
|
+
for (let i = at + 1; i < ordered.length; i++) {
|
|
1862
|
+
if (ordered[i].role === "assistant" && ordered[i].agent) return ordered[i].agent
|
|
1863
|
+
if (ordered[i].role === "user") break // next segment already began
|
|
1864
|
+
}
|
|
1865
|
+
return undefined
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/** user messages whose segment runs a DIFFERENT agent than the previous
|
|
1869
|
+
* segment carry metadata.agentSwitch = <agent> - the viewer renders the
|
|
1870
|
+
* transition chip from the stamp alone. */
|
|
1871
|
+
const stampAgentSwitches = (bySession: Map<string, IndexedMessage[]>) => {
|
|
1872
|
+
for (const [, list] of bySession) {
|
|
1873
|
+
list.sort((a, b) => a.created - b.created)
|
|
1874
|
+
const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0)
|
|
1875
|
+
let prev: string | undefined
|
|
1876
|
+
for (let k = 0; k < users.length; k++) {
|
|
1877
|
+
const agent = segmentAgent(list, users[k])
|
|
1878
|
+
const d = list[users[k]].data as Record<string, any>
|
|
1879
|
+
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
|
|
1880
|
+
// only touch metadata when the stamp actually CHANGES — a blanket
|
|
1881
|
+
// `d.metadata ??= {}` polluted every user message (including the
|
|
1882
|
+
// host's live objects on v2 pass-through) with an empty metadata
|
|
1883
|
+
// object, which re-emitted the message once per refresh (rev churn
|
|
1884
|
+
// per feed — live-caught by the enqueue-dedupe rev check)
|
|
1885
|
+
if (want !== (d.metadata?.agentSwitch as string | undefined)) {
|
|
1886
|
+
if (want) {
|
|
1887
|
+
const meta = (d.metadata ??= {}) as Record<string, unknown>
|
|
1888
|
+
meta.agentSwitch = want
|
|
1889
|
+
} else if (d.metadata) {
|
|
1890
|
+
delete d.metadata.agentSwitch
|
|
1891
|
+
}
|
|
1892
|
+
reEnqueueMessage(list[users[k]])
|
|
1893
|
+
}
|
|
1894
|
+
if (agent) prev = agent
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/** task/subagent tool calls get metadata.agent = the agent that actually
|
|
1900
|
+
* ran for THAT call: the child's first prompt created at/after the call
|
|
1901
|
+
* started (v1 prompts carry the agent; v2 resolves via the response).
|
|
1902
|
+
* Rank-free pairing - a resumed child's second call lands on its second
|
|
1903
|
+
* prompt by construction time. */
|
|
1904
|
+
const stampTaskAgents = (bySession: Map<string, IndexedMessage[]>) => {
|
|
1905
|
+
for (const list of bySession.values()) list.sort((a, b) => a.created - b.created)
|
|
1906
|
+
for (const m of msgIndex.values()) {
|
|
1907
|
+
if (m.role !== "assistant") continue
|
|
1908
|
+
const content = (m.data as any).content
|
|
1909
|
+
if (!Array.isArray(content)) continue
|
|
1910
|
+
let mutated = false
|
|
1911
|
+
for (const part of content) {
|
|
1912
|
+
if (!part || part.type !== "tool") continue
|
|
1913
|
+
if (part.name !== "task" && part.name !== "subagent") continue
|
|
1914
|
+
const state = part.state
|
|
1915
|
+
const meta = state?.metadata
|
|
1916
|
+
const childSid = typeof meta?.sessionID === "string" ? meta.sessionID : undefined
|
|
1917
|
+
if (!childSid || typeof meta.agent === "string") continue
|
|
1918
|
+
const partStart = Number(part.time?.created ?? 0)
|
|
1919
|
+
const child = bySession.get(childSid)
|
|
1920
|
+
if (!child) continue
|
|
1921
|
+
let agent: string | undefined
|
|
1922
|
+
for (const c of child) {
|
|
1923
|
+
if (c.created < partStart) continue
|
|
1924
|
+
if (c.role === "user" && c.agent) {
|
|
1925
|
+
agent = c.agent
|
|
1926
|
+
break
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
if (!agent) {
|
|
1930
|
+
for (const c of child) {
|
|
1931
|
+
if (c.created < partStart) continue
|
|
1932
|
+
if (c.role === "assistant" && c.agent) {
|
|
1933
|
+
agent = c.agent
|
|
1934
|
+
break
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
if (agent) {
|
|
1939
|
+
meta.agent = agent
|
|
1940
|
+
mutated = true
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
if (mutated) reEnqueueMessage(m)
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** file tools get metadata.language (shiki lang id, from the path the
|
|
1948
|
+
* call touched) - the viewer highlights from the stamp and never guesses
|
|
1949
|
+
* (owner directive; the viewer's extension map is an old-share fallback) */
|
|
1950
|
+
const LANG_BY_EXT: Record<string, string> = {
|
|
1951
|
+
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
|
1952
|
+
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
1953
|
+
json: "json", jsonc: "json", md: "markdown", mdx: "mdx",
|
|
1954
|
+
py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
|
|
1955
|
+
css: "css", scss: "scss", html: "html", xml: "xml", yaml: "yaml", yml: "yaml",
|
|
1956
|
+
toml: "toml", sh: "shellscript", bash: "shellscript", ps1: "powershell",
|
|
1957
|
+
sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
|
|
1958
|
+
swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
|
|
1959
|
+
}
|
|
1960
|
+
const FILE_TOOLS = new Set(["read", "write", "edit", "patch"])
|
|
1961
|
+
const stampFileLanguages = () => {
|
|
1962
|
+
for (const m of msgIndex.values()) {
|
|
1963
|
+
if (m.role !== "assistant") continue
|
|
1964
|
+
const content = (m.data as any).content
|
|
1965
|
+
if (!Array.isArray(content)) continue
|
|
1966
|
+
let mutated = false
|
|
1967
|
+
for (const part of content) {
|
|
1968
|
+
if (!part || part.type !== "tool") continue
|
|
1969
|
+
if (!FILE_TOOLS.has(part.name)) continue
|
|
1970
|
+
const state = part.state
|
|
1971
|
+
const meta = state?.metadata
|
|
1972
|
+
if (!meta) continue
|
|
1973
|
+
const raw = state.input?.path ?? state.input?.filePath
|
|
1974
|
+
// language stamp (shiki id from the path)
|
|
1975
|
+
if (typeof meta.language !== "string" && typeof raw === "string") {
|
|
1976
|
+
const ext = raw.split(".").pop()?.toLowerCase() ?? ""
|
|
1977
|
+
const lang = LANG_BY_EXT[ext]
|
|
1978
|
+
if (lang) {
|
|
1979
|
+
meta.language = lang
|
|
1980
|
+
mutated = true
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
// lineStart stamp: reads sliced from an offset keep REAL file line
|
|
1984
|
+
// numbers in the viewer's unified file card (pierre numbers from 1;
|
|
1985
|
+
// the viewer shifts by this stamp, authoritatively)
|
|
1986
|
+
if (typeof meta.lineStart !== "number" && part.name === "read") {
|
|
1987
|
+
const off = state.input?.offset
|
|
1988
|
+
if (typeof off === "number" && off >= 0 && off > 0) {
|
|
1989
|
+
meta.lineStart = off + 1
|
|
1990
|
+
mutated = true
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
if (mutated) reEnqueueMessage(m)
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
const stampsPass = (st: SessionState) => {
|
|
1999
|
+
// skip entirely when this flush carries no message items: every
|
|
2000
|
+
// index mutation rides a message item through the queue (inserts via
|
|
2001
|
+
// enqueue, deletes via tombstones — both are type "message"), so no
|
|
2002
|
+
// message in the queue means the stamps' inputs are unchanged. A
|
|
2003
|
+
// status-only/model/prefs/diff flush costs zero stamp work.
|
|
2004
|
+
let any = false
|
|
2005
|
+
for (const it of st.queue.values()) {
|
|
2006
|
+
if ((it as { type?: string }).type === "message") {
|
|
2007
|
+
any = true
|
|
2008
|
+
break
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
if (!any) return
|
|
2012
|
+
// one grouping + one sort shared by both passes (each used to build
|
|
2013
|
+
// and sort its own identical bySession map per flush)
|
|
2014
|
+
const bySession = new Map<string, IndexedMessage[]>()
|
|
2015
|
+
for (const m of msgIndex.values()) {
|
|
2016
|
+
const list = bySession.get(m.sid)
|
|
2017
|
+
if (list) list.push(m)
|
|
2018
|
+
else bySession.set(m.sid, [m])
|
|
2019
|
+
}
|
|
2020
|
+
for (const list of bySession.values()) list.sort((a, b) => a.created - b.created)
|
|
2021
|
+
stampAgentSwitches(bySession)
|
|
2022
|
+
stampTaskAgents(bySession)
|
|
2023
|
+
stampFileLanguages()
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// ---- contract v2 message assembly --------------------------------------
|
|
2027
|
+
// v1 hosts stream parts as separate events; the v2 item embeds content.
|
|
2028
|
+
// The engine keeps the assembled view per message and re-emits the
|
|
2029
|
+
// normalized whole (the keyed queue dedupes across a debounce window,
|
|
2030
|
+
// so intermediate re-emissions collapse).
|
|
2031
|
+
|
|
2032
|
+
const mergeEntry = (st: SessionState, messageID: string) => {
|
|
2033
|
+
let e = st.merged.get(messageID)
|
|
2034
|
+
if (!e) {
|
|
2035
|
+
e = { parts: new Map() }
|
|
2036
|
+
st.merged.set(messageID, e)
|
|
2037
|
+
}
|
|
2038
|
+
return e
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/** (Re-)emit the v2 message item for a merged v1 message. */
|
|
2042
|
+
const emitMerged = (st: SessionState, sid: string, messageID: string) => {
|
|
2043
|
+
const e = st.merged.get(messageID)
|
|
2044
|
+
if (!e?.info) return // parts arriving before their message: wait
|
|
2045
|
+
const res = normalizeV1Message(e.info, [...e.parts.values()])
|
|
2046
|
+
if (!res) return
|
|
2047
|
+
if ("refill" in res) {
|
|
2048
|
+
// v1 compaction summary assistant → rewrite the parent compaction
|
|
2049
|
+
// message with the summary text; the summary message itself is not
|
|
2050
|
+
// emitted as its own item
|
|
2051
|
+
const parent = st.merged.get(res.refill)
|
|
2052
|
+
if (parent?.info) {
|
|
2053
|
+
const compaction = normalizeV1Message(parent.info, [...parent.parts.values()])
|
|
2054
|
+
if (compaction && "message" in compaction) {
|
|
2055
|
+
const msg = compaction.message as Record<string, unknown>
|
|
2056
|
+
msg.summary = res.summary
|
|
2057
|
+
enqueue(st, itemFor("message", msg, sid))
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
return
|
|
2061
|
+
}
|
|
2062
|
+
enqueue(st, itemFor("message", res.message, sid))
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/** message.updated / part.updated ingestion for v1 hosts; v2 hosts call
|
|
2066
|
+
* with v2-native messages (pass-through, no merge). */
|
|
2067
|
+
const ingestMessageInfo = (st: SessionState, sid: string, info: Record<string, unknown>) => {
|
|
2068
|
+
if (isV2NativeMessage(info)) {
|
|
2069
|
+
enqueue(st, itemFor("message", info, sid))
|
|
2070
|
+
return
|
|
2071
|
+
}
|
|
2072
|
+
if (typeof info?.id !== "string") return
|
|
2073
|
+
const e = mergeEntry(st, info.id)
|
|
2074
|
+
e.info = info
|
|
2075
|
+
emitMerged(st, sid, info.id)
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const ingestPart = (st: SessionState, sid: string, part: Record<string, unknown>) => {
|
|
2079
|
+
if (typeof part?.id !== "string" || typeof part.messageID !== "string") return
|
|
2080
|
+
const e = mergeEntry(st, part.messageID)
|
|
2081
|
+
e.parts.set(part.id, part)
|
|
2082
|
+
emitMerged(st, sid, part.messageID)
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
const dropMergedMessage = (st: SessionState, messageID: string, evSid?: string) => {
|
|
2086
|
+
// the index corpse must go too: stampsPass iterates msgIndex, and a
|
|
2087
|
+
// re-stamp of a deleted message would re-upsert it (resurrection).
|
|
2088
|
+
// Key by the EVENT's session (children tombstone under their own sid;
|
|
2089
|
+
// the merged map is per main session)
|
|
2090
|
+
const sid = evSid ?? st.sid
|
|
2091
|
+
st.merged.delete(messageID)
|
|
2092
|
+
msgIndex.delete(`${sid}:${messageID}`)
|
|
2093
|
+
tombstoned.add(`${sid}:${messageID}`)
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const dropMergedPart = (st: SessionState, sid: string, messageID: string, partID: string) => {
|
|
2097
|
+
const e = st.merged.get(messageID)
|
|
2098
|
+
if (!e) return
|
|
2099
|
+
e.parts.delete(partID)
|
|
2100
|
+
if (e.info) emitMerged(st, sid, messageID)
|
|
2101
|
+
}
|
|
2102
|
+
|
|
1419
2103
|
const remove = (st: SessionState) => {
|
|
1420
2104
|
st.closed = true
|
|
1421
2105
|
st.activated = false
|
|
@@ -1432,6 +2116,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1432
2116
|
clearTimeout(r.initTimer)
|
|
1433
2117
|
r.initTimer = null
|
|
1434
2118
|
}
|
|
2119
|
+
if (r.backoffResetTimer) {
|
|
2120
|
+
clearTimeout(r.backoffResetTimer)
|
|
2121
|
+
r.backoffResetTimer = null
|
|
2122
|
+
}
|
|
1435
2123
|
if (r.reconnectTimer) {
|
|
1436
2124
|
clearTimeout(r.reconnectTimer)
|
|
1437
2125
|
r.reconnectTimer = null
|
|
@@ -1441,8 +2129,28 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1441
2129
|
clearTimeout(r.idleDropTimer)
|
|
1442
2130
|
r.idleDropTimer = null
|
|
1443
2131
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
2132
|
+
// wsReady delete only when THIS remote still owns a share: a stray
|
|
2133
|
+
// child teardown clears shareId after adopting into the parent —
|
|
2134
|
+
// the ControlDO's server-side attach would otherwise delete the
|
|
2135
|
+
// very share the parent just inherited (live-caught adoption churn)
|
|
2136
|
+
if (r.wsReady && r.shareId) {
|
|
2137
|
+
wsSend(r, { op: "delete" })
|
|
2138
|
+
// backstop: the WS delete is fire-and-forget on the wire — a
|
|
2139
|
+
// socket dying right after leaves the page public until TTL. The
|
|
2140
|
+
// HTTP goodbye is idempotent (404/ok both fine).
|
|
2141
|
+
if (r.shareId && r.secret) {
|
|
2142
|
+
const sid = r.shareId
|
|
2143
|
+
const sec = r.secret
|
|
2144
|
+
setTimeout(() => {
|
|
2145
|
+
void fetch(`${r.url}/api/share/${sid}`, {
|
|
2146
|
+
method: "DELETE",
|
|
2147
|
+
headers: { "content-type": "application/json" },
|
|
2148
|
+
body: JSON.stringify({ secret: sec }),
|
|
2149
|
+
signal: AbortSignal.timeout(8000),
|
|
2150
|
+
}).catch(() => {})
|
|
2151
|
+
}, 2000)
|
|
2152
|
+
}
|
|
2153
|
+
} else if (r.shareId && r.secret) {
|
|
1446
2154
|
// turn mode may have dropped the socket after the last settle; the
|
|
1447
2155
|
// goodbye still must land — say it over HTTP
|
|
1448
2156
|
void fetch(`${r.url}/api/share/${r.shareId}`, {
|
|
@@ -1461,6 +2169,15 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1461
2169
|
}
|
|
1462
2170
|
}, 1000)
|
|
1463
2171
|
for (const r of st.remotes.values()) spoolClear(st, r)
|
|
2172
|
+
// memory hygiene: the session is gone — its index corpses, tombstone
|
|
2173
|
+
// guards and child routing must not linger (msgIndex/tombstoned are
|
|
2174
|
+
// engine-global and otherwise only grow; sentHash rides the session
|
|
2175
|
+
// state and dies with it)
|
|
2176
|
+
const stSid = st.sid
|
|
2177
|
+
const pfx = `${stSid}:`
|
|
2178
|
+
for (const k of msgIndex.keys()) if (k.startsWith(pfx)) msgIndex.delete(k)
|
|
2179
|
+
for (const k of tombstoned) if (k.startsWith(pfx)) tombstoned.delete(k)
|
|
2180
|
+
for (const [child, parent] of childToParent) if (parent === stSid || child === stSid) childToParent.delete(child)
|
|
1464
2181
|
sessions.delete(st.sid)
|
|
1465
2182
|
try {
|
|
1466
2183
|
chain = chain.then(async () => {
|
|
@@ -1496,7 +2213,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1496
2213
|
|
|
1497
2214
|
const query = () => (deps.directory ? { directory: deps.directory } : {})
|
|
1498
2215
|
|
|
1499
|
-
async function fetchMessages(client: ClientApi, sid: string): Promise<Message[]> {
|
|
2216
|
+
async function fetchMessages(client: ClientApi, sid: string, onPage?: (page: Message[]) => void): Promise<Message[]> {
|
|
1500
2217
|
if (typeof client.session.messages !== "function") return []
|
|
1501
2218
|
const out: Message[] = []
|
|
1502
2219
|
let before: string | undefined
|
|
@@ -1511,13 +2228,33 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1511
2228
|
)
|
|
1512
2229
|
if (!Array.isArray(result) || result.length === 0) break
|
|
1513
2230
|
out.push(...(result as Message[]))
|
|
2231
|
+
// stream pages as they arrive: enqueue (and the rate-limited flush)
|
|
2232
|
+
// starts with page 1 while later pages are still loading — a huge
|
|
2233
|
+
// session backfills progressively instead of after a full collect
|
|
2234
|
+
if (onPage) {
|
|
2235
|
+
try {
|
|
2236
|
+
onPage(result as Message[])
|
|
2237
|
+
} catch {}
|
|
2238
|
+
}
|
|
1514
2239
|
if (result.length < PAGE_SIZE) break
|
|
1515
2240
|
const last = result[result.length - 1] as Message
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
2241
|
+
const lastInfo = (last?.info ?? last) as Record<string, unknown> | undefined
|
|
2242
|
+
const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined
|
|
2243
|
+
// `before` is an OPAQUE cursor: base64url({id, time}) of the page
|
|
2244
|
+
// tail (message-v2.ts codec). A bare message id 400s on hosts with
|
|
2245
|
+
// cursor pagination — every v1 backfill silently truncated to one
|
|
2246
|
+
// 100-message page (live-caught).
|
|
2247
|
+
const lastTime = (lastInfo?.time as Record<string, unknown> | undefined)?.created
|
|
2248
|
+
if (!lastID || typeof lastTime !== "number") break
|
|
2249
|
+
const nextBefore = cursorFor(lastID, lastTime)
|
|
2250
|
+
if (nextBefore === before) break
|
|
1519
2251
|
before = nextBefore
|
|
1520
2252
|
}
|
|
2253
|
+
// the page cap is a hard truncation: surface it instead of silently
|
|
2254
|
+
// sharing a partial history (oldest pages dropped)
|
|
2255
|
+
if (out.length >= MAX_PAGES * PAGE_SIZE) {
|
|
2256
|
+
logError(`backfill truncated at ${out.length} messages (page cap) for ${sid.slice(0, 18)} — oldest history not shared`)
|
|
2257
|
+
}
|
|
1521
2258
|
return out
|
|
1522
2259
|
}
|
|
1523
2260
|
|
|
@@ -1525,27 +2262,32 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1525
2262
|
const sessionInfoValue = info ?? (await sessionInfo(client, sid))
|
|
1526
2263
|
if (sessionInfoValue) enqueue(st, itemFor("session", redact(sessionInfoValue), sid))
|
|
1527
2264
|
|
|
1528
|
-
const messages = await fetchMessages(client, sid)
|
|
1529
2265
|
let modelSeen = false
|
|
1530
|
-
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
if (
|
|
1545
|
-
|
|
1546
|
-
|
|
2266
|
+
const consume = (messages: Message[]) => {
|
|
2267
|
+
for (const message of messages) {
|
|
2268
|
+
const m = ((message.info ?? message) as MessageInfo) as Record<string, unknown> | undefined
|
|
2269
|
+
if (!m || typeof m.id !== "string") continue
|
|
2270
|
+
// seed the merge state, then emit the assembled v2 message
|
|
2271
|
+
const e = mergeEntry(st, m.id)
|
|
2272
|
+
e.info = m
|
|
2273
|
+
for (const rawPart of (message.parts ?? []) as Array<Record<string, unknown>>) {
|
|
2274
|
+
const p = rawPart as PartInfo
|
|
2275
|
+
if (!p || typeof p.id !== "string" || typeof p.messageID !== "string") continue
|
|
2276
|
+
if (p.state?.status === "error") st.badSignal = true
|
|
2277
|
+
e.parts.set(p.id, p as Record<string, unknown>)
|
|
2278
|
+
}
|
|
2279
|
+
emitMerged(st, sid, m.id)
|
|
2280
|
+
if (m.role === "user" && m.model) {
|
|
2281
|
+
modelSeen = true
|
|
2282
|
+
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model as SessionModel)
|
|
2283
|
+
enqueue(
|
|
2284
|
+
st,
|
|
2285
|
+
itemFor("model", [{ id: (m.model as SessionModel).modelID, providerID: (m.model as SessionModel).providerID, name: label }], sid),
|
|
2286
|
+
)
|
|
2287
|
+
}
|
|
1547
2288
|
}
|
|
1548
2289
|
}
|
|
2290
|
+
const messages = await fetchMessages(client, sid, (page) => consume(page))
|
|
1549
2291
|
if (!modelSeen && sessionInfoValue?.model) {
|
|
1550
2292
|
// hosts whose user messages carry no model (v2): label from the
|
|
1551
2293
|
// session's own model
|
|
@@ -1800,8 +2542,13 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1800
2542
|
// runs still land their final status.
|
|
1801
2543
|
if (st.timer) clearTimeout(st.timer)
|
|
1802
2544
|
st.timer = null
|
|
2545
|
+
// detached flush (HTTP fallback off the shared chain — one
|
|
2546
|
+
// black-holing server must not stall the fan-out; same rule
|
|
2547
|
+
// as scheduleFlush)
|
|
1803
2548
|
chain = chain
|
|
1804
|
-
.then(() =>
|
|
2549
|
+
.then(() => {
|
|
2550
|
+
flush(st)
|
|
2551
|
+
})
|
|
1805
2552
|
.catch((e) => logError("idle flush error", (e as Error)?.message || e))
|
|
1806
2553
|
const epoch = st.statusEpoch
|
|
1807
2554
|
const settle = () => {
|
|
@@ -1810,7 +2557,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1810
2557
|
if (st.timer) clearTimeout(st.timer)
|
|
1811
2558
|
st.timer = null
|
|
1812
2559
|
chain = chain
|
|
1813
|
-
.then(() =>
|
|
2560
|
+
.then(() => {
|
|
2561
|
+
flush(st)
|
|
2562
|
+
})
|
|
1814
2563
|
.then(() => {
|
|
1815
2564
|
armSettleDrop(st)
|
|
1816
2565
|
})
|
|
@@ -1842,19 +2591,24 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1842
2591
|
// opencode undo: revert() only marks the session (the viewer
|
|
1843
2592
|
// hides the range); the NEXT prompt hard-deletes the reverted
|
|
1844
2593
|
// messages/parts via cleanup() and publishes these events.
|
|
1845
|
-
//
|
|
1846
|
-
//
|
|
1847
|
-
//
|
|
2594
|
+
// A message tombstone removes the whole v2 message (content is
|
|
2595
|
+
// embedded); a part removal re-emits the merged message minus
|
|
2596
|
+
// the part.
|
|
1848
2597
|
const st = stFor(sid)
|
|
1849
2598
|
if (!st || !st.activated || st.closed) return
|
|
1850
2599
|
const messageID = p.messageID as string | undefined
|
|
1851
2600
|
if (!messageID) return
|
|
1852
2601
|
if (t === "message.removed") {
|
|
1853
|
-
|
|
2602
|
+
// key/tombstone by the EVENT's session id (a child's message
|
|
2603
|
+
// must not be keyed onto the parent — the worker deletes the
|
|
2604
|
+
// item but the viewer's tombstone key would miss and the
|
|
2605
|
+
// msgIndex corpse could re-upsert it)
|
|
2606
|
+
dropMergedMessage(st, messageID, sid)
|
|
2607
|
+
enqueue(st, { key: `message/${messageID}`, item: messageTombstone(sid, messageID) })
|
|
1854
2608
|
} else {
|
|
1855
2609
|
const partID = p.partID as string | undefined
|
|
1856
2610
|
if (!partID) return
|
|
1857
|
-
|
|
2611
|
+
dropMergedPart(st, sid, messageID, partID)
|
|
1858
2612
|
}
|
|
1859
2613
|
} else if (t === "message.updated") {
|
|
1860
2614
|
// resumed sessions may emit the user message BEFORE any session.*
|
|
@@ -1907,7 +2661,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1907
2661
|
}
|
|
1908
2662
|
}
|
|
1909
2663
|
if (!st || !st.activated || st.closed) return
|
|
1910
|
-
|
|
2664
|
+
ingestMessageInfo(st, sid, p.info as Record<string, unknown>)
|
|
1911
2665
|
const info = p.info as MessageInfo
|
|
1912
2666
|
if (info?.role === "user" && info?.model) {
|
|
1913
2667
|
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model)
|
|
@@ -1916,8 +2670,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1916
2670
|
// an assistant message that DIED (finish:"error") — e.g. all
|
|
1917
2671
|
// retries exhausted — surfaces as a 200 response with an errored
|
|
1918
2672
|
// message, NOT as session.error (live-caught in the suite); the
|
|
1919
|
-
// share must still say error
|
|
1920
|
-
|
|
2673
|
+
// share must still say error. Works for v2-native messages too
|
|
2674
|
+
// (finish lives on the message itself).
|
|
2675
|
+
if ((info as { finish?: string; role?: string })?.finish === "error") {
|
|
1921
2676
|
st.badSignal = true
|
|
1922
2677
|
}
|
|
1923
2678
|
} else if (t === "message.part.updated") {
|
|
@@ -1925,11 +2680,11 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1925
2680
|
if (!st || !st.activated || st.closed) return
|
|
1926
2681
|
const part = p.part as PartInfo
|
|
1927
2682
|
if (part?.state?.status === "error") st.badSignal = true
|
|
1928
|
-
|
|
2683
|
+
ingestPart(st, sid, part as Record<string, unknown>)
|
|
1929
2684
|
} else if (t === "session.diff") {
|
|
1930
2685
|
const st = stFor(sid)
|
|
1931
2686
|
if (!st || !st.activated || st.closed) return
|
|
1932
|
-
enqueue(st, itemFor("session_diff", p.diff, sid))
|
|
2687
|
+
enqueue(st, itemFor("session_diff", normalizeV1Diff(p.diff), sid))
|
|
1933
2688
|
} else if (t === "session.deleted") {
|
|
1934
2689
|
// a CHILD session ending (task done, gc) must NEVER take the
|
|
1935
2690
|
// parent's share down with it — the page keeps the subagent's
|
|
@@ -1979,6 +2734,16 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1979
2734
|
await flushViaHttp(st, r).catch(() => {})
|
|
1980
2735
|
}
|
|
1981
2736
|
}
|
|
2737
|
+
// resolve any pending debounced spool rewrite before exit: the
|
|
2738
|
+
// queue lines are already on disk (appends are sync), but the
|
|
2739
|
+
// meta's ackedRev and the compacted queue should be final
|
|
2740
|
+
for (const r of st.remotes.values()) {
|
|
2741
|
+
if (r.spoolSaveTimer) {
|
|
2742
|
+
clearTimeout(r.spoolSaveTimer)
|
|
2743
|
+
r.spoolSaveTimer = null
|
|
2744
|
+
spoolSave(st, r)
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
1982
2747
|
if (st.metaTimer) clearTimeout(st.metaTimer)
|
|
1983
2748
|
st.metaTimer = null
|
|
1984
2749
|
await writeBinding(st).catch(() => {})
|