@mirrowel/opencode-openshare 0.2.0-dev.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -37
- package/ci/README.md +9 -3
- package/ci/openshare.js +379 -85
- package/dist/binding-queue.d.ts +1 -0
- package/dist/binding-queue.js +22 -0
- package/dist/commands.js +41 -285
- package/dist/core.js +524 -88
- package/dist/dialogs.js +1 -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.js +0 -21
- package/dist/tui.js +256 -386
- package/dist/v2-server.js +55 -0
- package/package.json +1 -1
- package/src/binding-queue.ts +26 -0
- package/src/commands.ts +41 -280
- package/src/core.ts +505 -87
- package/src/dialogs.tsx +11 -2
- package/src/settings.ts +133 -15
- package/src/share-flow.ts +33 -19
- package/src/sidebar.tsx +0 -24
- package/src/v2-server.ts +50 -0
package/src/core.ts
CHANGED
|
@@ -43,9 +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"
|
|
48
49
|
import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
|
|
50
|
+
import { queueBindingWrite } from "./binding-queue.js"
|
|
49
51
|
|
|
50
52
|
// --- minimal structural types (hand-written: the standalone bundle must
|
|
51
53
|
// not depend on the plugin SDK at runtime; shapes verified against
|
|
@@ -107,13 +109,21 @@ type RemoteState = {
|
|
|
107
109
|
initTimer: ReturnType<typeof setTimeout> | null
|
|
108
110
|
initWasAttach: boolean
|
|
109
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
|
|
110
116
|
reconnectTimer: ReturnType<typeof setTimeout> | null
|
|
111
117
|
intentionalClose: boolean
|
|
112
118
|
connectQueued: boolean
|
|
113
119
|
/** identity dead-end for THIS server (frozen owner, unenrollable
|
|
114
120
|
* private server): stop reconnecting it; other servers unaffected. */
|
|
115
121
|
authBlocked: boolean
|
|
116
|
-
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
|
|
117
127
|
announced: boolean
|
|
118
128
|
settleTimer: ReturnType<typeof setTimeout> | null
|
|
119
129
|
idleDropTimer: ReturnType<typeof setTimeout> | null
|
|
@@ -153,6 +163,12 @@ type SessionState = {
|
|
|
153
163
|
* item embeds content, so the engine holds the assembled view per
|
|
154
164
|
* message and re-emits the normalized whole on every update */
|
|
155
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>
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
type Message = { info?: Record<string, unknown>; parts?: Record<string, unknown>[] }
|
|
@@ -420,6 +436,8 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
420
436
|
initTimer: null,
|
|
421
437
|
initWasAttach: false,
|
|
422
438
|
backoff: 1000,
|
|
439
|
+
backoffResetTimer: null,
|
|
440
|
+
spoolSaveTimer: null,
|
|
423
441
|
reconnectTimer: null,
|
|
424
442
|
intentionalClose: false,
|
|
425
443
|
connectQueued: false,
|
|
@@ -485,12 +503,19 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
485
503
|
if (b && typeof b === "object" && (b as { policy?: unknown }).policy) {
|
|
486
504
|
applyPolicy((b as { policy: unknown }).policy, url)
|
|
487
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
|
+
}
|
|
488
512
|
})
|
|
489
513
|
.catch(() => {
|
|
490
514
|
// unreachable worker: defaults stay; the init ack will correct us
|
|
491
515
|
policyAsked.delete(url)
|
|
492
516
|
})
|
|
493
517
|
}
|
|
518
|
+
const frameAuthUrls = new Set<string>()
|
|
494
519
|
const wsUrl = (url: string) => url.replace(/^http/, "ws")
|
|
495
520
|
|
|
496
521
|
// ---- spool root ---------------------------------------------------------
|
|
@@ -534,6 +559,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
534
559
|
wroteBinding: false,
|
|
535
560
|
everConnected: false,
|
|
536
561
|
merged: new Map(),
|
|
562
|
+
sentHash: new Map(),
|
|
537
563
|
})
|
|
538
564
|
|
|
539
565
|
const refreshConfig = () => {
|
|
@@ -572,6 +598,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
572
598
|
}
|
|
573
599
|
st.activated = true
|
|
574
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()
|
|
575
604
|
const gated = mode === "manual" || opts2?.gated === true
|
|
576
605
|
if (gated) {
|
|
577
606
|
// seed the share status from the session's CURRENT state before
|
|
@@ -647,26 +676,31 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
647
676
|
|
|
648
677
|
async function writeMetadata(st: SessionState, value: Record<string, unknown> | null) {
|
|
649
678
|
if (!st.client) return
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
+
})
|
|
670
704
|
}
|
|
671
705
|
|
|
672
706
|
/** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
|
|
@@ -777,21 +811,51 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
777
811
|
const spoolSave = (st: SessionState, r: RemoteState) => {
|
|
778
812
|
try {
|
|
779
813
|
writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }))
|
|
780
|
-
|
|
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 })
|
|
781
822
|
} catch (e) {
|
|
782
823
|
logError("spool save failed", (e as Error)?.message || e)
|
|
783
824
|
}
|
|
784
825
|
}
|
|
785
826
|
// hot path: appending one pending frame must not re-serialize the whole
|
|
786
827
|
// queue (O(n^2) during backfill bursts) — append the line instead
|
|
787
|
-
const spoolAppend = (st: SessionState, r: RemoteState, f: { items: unknown[]; status: string }) => {
|
|
828
|
+
const spoolAppend = (st: SessionState, r: RemoteState, f: { items: unknown[]; status: string; seq?: number; wire?: string }) => {
|
|
788
829
|
try {
|
|
789
|
-
appendFileSync(queuePath(st, r.url), JSON.stringify(f) + "\n")
|
|
830
|
+
appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n")
|
|
790
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
|
|
791
836
|
logError("spool append failed", (e as Error)?.message || e)
|
|
792
837
|
}
|
|
793
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
|
+
}
|
|
794
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).
|
|
795
859
|
try {
|
|
796
860
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
|
|
797
861
|
// metadata binding (db) is authoritative when both exist and disagree
|
|
@@ -800,23 +864,40 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
800
864
|
r.secret = meta.secret
|
|
801
865
|
r.ackedRev = meta.ackedRev ?? 0
|
|
802
866
|
}
|
|
867
|
+
} catch {}
|
|
868
|
+
try {
|
|
803
869
|
if (existsSync(queuePath(st, r.url))) {
|
|
804
870
|
// append-only spool: a crash mid-append can tear the LAST line —
|
|
805
|
-
// keep the valid prefix instead of dropping the whole queue
|
|
806
|
-
|
|
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 }> = []
|
|
807
877
|
for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
|
|
808
878
|
if (!line.trim()) continue
|
|
809
879
|
try {
|
|
810
|
-
|
|
880
|
+
disk.push(JSON.parse(line))
|
|
811
881
|
} catch {
|
|
812
882
|
log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`)
|
|
813
883
|
break
|
|
814
884
|
}
|
|
815
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
|
|
816
899
|
}
|
|
817
|
-
} catch {
|
|
818
|
-
r.pending = []
|
|
819
|
-
}
|
|
900
|
+
} catch {}
|
|
820
901
|
// single-server-era spool (no url suffix): belongs to the first target
|
|
821
902
|
if (!r.shareId && targets[0]?.url === r.url) {
|
|
822
903
|
try {
|
|
@@ -838,12 +919,27 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
838
919
|
}
|
|
839
920
|
const spoolClear = (st: SessionState, r: RemoteState) => {
|
|
840
921
|
try {
|
|
922
|
+
if (r.spoolSaveTimer) {
|
|
923
|
+
clearTimeout(r.spoolSaveTimer)
|
|
924
|
+
r.spoolSaveTimer = null
|
|
925
|
+
}
|
|
841
926
|
rmSync(metaPath(st, r.url), { force: true })
|
|
842
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 })
|
|
843
931
|
} catch {}
|
|
844
932
|
}
|
|
845
933
|
|
|
846
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
|
+
|
|
847
943
|
const wsSend = (r: RemoteState, obj: unknown) => {
|
|
848
944
|
if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${(obj as { op?: string })?.op} (ready=${r.wsReady})`)
|
|
849
945
|
try {
|
|
@@ -861,31 +957,61 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
861
957
|
* and rebroadcast; worker apply is keyed-idempotent, so chunks just
|
|
862
958
|
* bump rev. An item larger than the whole budget rides alone in its
|
|
863
959
|
* own frame (the worker fits oversize items to its storage cap).
|
|
864
|
-
* Status rides each chunk; last write wins.
|
|
865
|
-
|
|
866
|
-
|
|
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 }> = []
|
|
867
967
|
let cur: unknown[] = []
|
|
968
|
+
let curJson: string[] = []
|
|
868
969
|
let curBytes = 0
|
|
869
970
|
for (const it of items) {
|
|
870
|
-
const
|
|
871
|
-
if (cur.length && (cur.length >= maxItems || curBytes +
|
|
872
|
-
chunks.push(cur)
|
|
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(",") + "]" })
|
|
873
974
|
cur = []
|
|
975
|
+
curJson = []
|
|
874
976
|
curBytes = 0
|
|
875
977
|
}
|
|
876
978
|
cur.push(it)
|
|
877
|
-
|
|
979
|
+
curJson.push(j)
|
|
980
|
+
curBytes += j.length
|
|
878
981
|
}
|
|
879
|
-
if (cur.length) chunks.push(cur)
|
|
982
|
+
if (cur.length) chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" })
|
|
880
983
|
return chunks
|
|
881
984
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
886
999
|
}
|
|
887
1000
|
return true
|
|
888
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))
|
|
1011
|
+
}
|
|
1012
|
+
for (const c of chunkedFrames(f.items, 200, 512 * 1024)) wsSendRaw(r, syncFrameStr(c.json, f.status, f.seq))
|
|
1013
|
+
return true
|
|
1014
|
+
}
|
|
889
1015
|
|
|
890
1016
|
const announceUrl = (st: SessionState, r: RemoteState) => {
|
|
891
1017
|
if (r.announced) return
|
|
@@ -1062,7 +1188,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1062
1188
|
logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`)
|
|
1063
1189
|
return
|
|
1064
1190
|
}
|
|
1065
|
-
|
|
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 ?? "")}`}`)
|
|
1066
1197
|
r.ws = ws
|
|
1067
1198
|
await new Promise((resolve, reject) => {
|
|
1068
1199
|
const t = setTimeout(() => reject(new Error("ws open timeout")), 15000)
|
|
@@ -1100,6 +1231,31 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1100
1231
|
if (!st.closed) scheduleReconnect(st, r)
|
|
1101
1232
|
})
|
|
1102
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
|
+
|
|
1103
1259
|
if (r.initWasAttach) {
|
|
1104
1260
|
wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } })
|
|
1105
1261
|
} else {
|
|
@@ -1129,7 +1285,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1129
1285
|
|
|
1130
1286
|
const scheduleReconnect = (st: SessionState, r: RemoteState) => {
|
|
1131
1287
|
if (st.closed || r.reconnectTimer) return
|
|
1132
|
-
|
|
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)))
|
|
1133
1291
|
r.backoff = Math.min(r.backoff * 2, 60000)
|
|
1134
1292
|
r.reconnectTimer = setTimeout(() => {
|
|
1135
1293
|
r.reconnectTimer = null
|
|
@@ -1215,6 +1373,15 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1215
1373
|
}
|
|
1216
1374
|
|
|
1217
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
|
+
}
|
|
1218
1385
|
if (frame.op === "init") {
|
|
1219
1386
|
if (!frame.ok) {
|
|
1220
1387
|
const err = String(frame.error ?? "")
|
|
@@ -1268,6 +1435,19 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1268
1435
|
// bad base (mismatch protocol). Content REPLAYS to the new share —
|
|
1269
1436
|
// pending is never dropped on a reset (live-caught: the recreate
|
|
1270
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
|
+
}
|
|
1271
1451
|
logError(`${r.url}: init refused:`, frame.error, "- creating fresh share")
|
|
1272
1452
|
deleteAbandoned(r)
|
|
1273
1453
|
r.shareId = null
|
|
@@ -1298,11 +1478,23 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1298
1478
|
r.connecting = false
|
|
1299
1479
|
r.initWasAttach = false
|
|
1300
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
|
|
1301
1484
|
if (r.initTimer) {
|
|
1302
1485
|
clearTimeout(r.initTimer)
|
|
1303
1486
|
r.initTimer = null
|
|
1304
1487
|
}
|
|
1305
|
-
|
|
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)
|
|
1306
1498
|
st.everConnected = true
|
|
1307
1499
|
if (frame.policy) applyPolicy(frame.policy, r.url)
|
|
1308
1500
|
r.intentionalClose = false
|
|
@@ -1324,19 +1516,67 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1324
1516
|
if (wasFanoutCreate) {
|
|
1325
1517
|
// a server joined an ALREADY-live session (fan-out add, or a
|
|
1326
1518
|
// post-mismatch recreate): its fresh share needs the full history —
|
|
1327
|
-
// 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()
|
|
1328
1525
|
chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", (e as Error)?.message || e))
|
|
1329
1526
|
}
|
|
1330
1527
|
return
|
|
1331
1528
|
}
|
|
1332
1529
|
if (frame.op === "ack") {
|
|
1333
1530
|
r.ackedRev = (frame.rev as number) ?? r.ackedRev
|
|
1334
|
-
|
|
1335
|
-
|
|
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)
|
|
1336
1547
|
scheduleBindingWrite(st)
|
|
1337
1548
|
return
|
|
1338
1549
|
}
|
|
1339
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
|
+
}
|
|
1340
1580
|
logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200))
|
|
1341
1581
|
// the control socket lost its attach state (worker-side eviction of
|
|
1342
1582
|
// the control DO): force a reconnect — init attach replays pending
|
|
@@ -1371,7 +1611,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1371
1611
|
// stamps run at frame time: the whole family's data is indexed by now
|
|
1372
1612
|
// (child prompts are created during the task call, well inside the
|
|
1373
1613
|
// flush debounce), and any re-enqueued message lands in this frame
|
|
1374
|
-
stampsPass()
|
|
1614
|
+
stampsPass(st)
|
|
1375
1615
|
const items = Array.from(st.queue.values())
|
|
1376
1616
|
st.queue.clear()
|
|
1377
1617
|
const statusChanged = st.status !== st.lastSentStatus
|
|
@@ -1393,44 +1633,99 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1393
1633
|
if (!f) return
|
|
1394
1634
|
// fan out to every remote; each carries its own pending copy and acks
|
|
1395
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) : []
|
|
1396
1640
|
let httpWork: Promise<unknown> | undefined
|
|
1397
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
|
+
}
|
|
1398
1660
|
// never send syncs before the init ack: the worker's per-socket
|
|
1399
1661
|
// ControlDO has no attach until OUR init completes, and a racing
|
|
1400
1662
|
// sync dies as "unknown op" (live-caught: post-drop reconnects
|
|
1401
1663
|
// lost whole turns). The init-ok handler flushes pending itself.
|
|
1402
|
-
r.pending.push(f)
|
|
1403
|
-
spoolAppend(st, r, f)
|
|
1404
1664
|
if (r.ws && !r.wsReady) continue
|
|
1405
1665
|
if (r.wsReady) {
|
|
1406
|
-
sendSync(r,
|
|
1666
|
+
for (const cf of fresh) sendSync(r, cf)
|
|
1407
1667
|
continue
|
|
1408
1668
|
}
|
|
1409
1669
|
const viaHttp = flushViaHttp(st, r)
|
|
1410
1670
|
if (viaHttp) httpWork = (httpWork ?? Promise.resolve()).then(() => viaHttp)
|
|
1411
1671
|
}
|
|
1412
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))
|
|
1413
1676
|
return httpWork
|
|
1414
1677
|
}
|
|
1415
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
|
+
|
|
1416
1696
|
const flushViaHttp = async (st: SessionState, r: RemoteState) => {
|
|
1417
1697
|
if (!r.shareId || !r.pending.length) return
|
|
1418
1698
|
// ALL pending frames, merged (keyed upserts — later wins). Sending
|
|
1419
1699
|
// only the last frame dropped every earlier batch's items whenever
|
|
1420
1700
|
// the HTTP fallback carried more than one (live-caught class:
|
|
1421
|
-
// content loss on reconnect paths).
|
|
1422
|
-
|
|
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[] = []
|
|
1423
1707
|
let status: string | undefined
|
|
1708
|
+
const sentSeqs = new Set<number>()
|
|
1424
1709
|
for (const f of r.pending) {
|
|
1425
|
-
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
|
+
}
|
|
1426
1715
|
if (f.status) status = f.status
|
|
1716
|
+
if (typeof f.seq === "number") sentSeqs.add(f.seq)
|
|
1427
1717
|
}
|
|
1718
|
+
const items = [...byKey.values(), ...unkeyed]
|
|
1428
1719
|
const cred = credOf(r.url)
|
|
1429
1720
|
// chunked like WS frames: one unbounded body is a whole-backlog
|
|
1430
1721
|
// bomb after a mid-backfill socket drop (backfill = full history).
|
|
1431
1722
|
// Failure stops the loop with pending intact — replaying already-
|
|
1432
1723
|
// applied chunks is idempotent.
|
|
1433
|
-
|
|
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) {
|
|
1434
1729
|
const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
|
|
1435
1730
|
method: "POST",
|
|
1436
1731
|
headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
|
|
@@ -1441,8 +1736,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1441
1736
|
const body = (await res.json().catch(() => ({}))) as { rev?: number }
|
|
1442
1737
|
r.ackedRev = body.rev ?? r.ackedRev
|
|
1443
1738
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
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)
|
|
1446
1745
|
}
|
|
1447
1746
|
|
|
1448
1747
|
const scheduleFlush = (st: SessionState) => {
|
|
@@ -1457,12 +1756,38 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1457
1756
|
st.timer = setTimeout(() => {
|
|
1458
1757
|
st.timer = null
|
|
1459
1758
|
if (debugEvents) log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`)
|
|
1460
|
-
|
|
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))
|
|
1461
1768
|
}, wait)
|
|
1462
1769
|
}
|
|
1463
1770
|
|
|
1464
1771
|
const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
|
|
1465
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)
|
|
1466
1791
|
st.queue.set(entry.key, entry.item)
|
|
1467
1792
|
indexMessage(entry)
|
|
1468
1793
|
if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
|
|
@@ -1485,6 +1810,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1485
1810
|
data: Record<string, unknown>
|
|
1486
1811
|
}
|
|
1487
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>()
|
|
1488
1819
|
|
|
1489
1820
|
const indexMessage = (entry: ItemEntry) => {
|
|
1490
1821
|
if (entry.item.type !== "message") return
|
|
@@ -1514,6 +1845,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1514
1845
|
sessions.get(childToParent.get(sid) ?? sid)
|
|
1515
1846
|
|
|
1516
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
|
|
1517
1851
|
const st = sessionStateOf(m.sid)
|
|
1518
1852
|
if (st) enqueue(st, itemFor("message", m.data, m.sid))
|
|
1519
1853
|
}
|
|
@@ -1534,13 +1868,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1534
1868
|
/** user messages whose segment runs a DIFFERENT agent than the previous
|
|
1535
1869
|
* segment carry metadata.agentSwitch = <agent> - the viewer renders the
|
|
1536
1870
|
* transition chip from the stamp alone. */
|
|
1537
|
-
const stampAgentSwitches = () => {
|
|
1538
|
-
const bySession = new Map<string, IndexedMessage[]>()
|
|
1539
|
-
for (const m of msgIndex.values()) {
|
|
1540
|
-
const list = bySession.get(m.sid)
|
|
1541
|
-
if (list) list.push(m)
|
|
1542
|
-
else bySession.set(m.sid, [m])
|
|
1543
|
-
}
|
|
1871
|
+
const stampAgentSwitches = (bySession: Map<string, IndexedMessage[]>) => {
|
|
1544
1872
|
for (const [, list] of bySession) {
|
|
1545
1873
|
list.sort((a, b) => a.created - b.created)
|
|
1546
1874
|
const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0)
|
|
@@ -1548,11 +1876,19 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1548
1876
|
for (let k = 0; k < users.length; k++) {
|
|
1549
1877
|
const agent = segmentAgent(list, users[k])
|
|
1550
1878
|
const d = list[users[k]].data as Record<string, any>
|
|
1551
|
-
const meta = (d.metadata ??= {}) as Record<string, unknown>
|
|
1552
1879
|
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
+
}
|
|
1556
1892
|
reEnqueueMessage(list[users[k]])
|
|
1557
1893
|
}
|
|
1558
1894
|
if (agent) prev = agent
|
|
@@ -1565,13 +1901,7 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1565
1901
|
* started (v1 prompts carry the agent; v2 resolves via the response).
|
|
1566
1902
|
* Rank-free pairing - a resumed child's second call lands on its second
|
|
1567
1903
|
* prompt by construction time. */
|
|
1568
|
-
const stampTaskAgents = () => {
|
|
1569
|
-
const bySession = new Map<string, IndexedMessage[]>()
|
|
1570
|
-
for (const m of msgIndex.values()) {
|
|
1571
|
-
const list = bySession.get(m.sid)
|
|
1572
|
-
if (list) list.push(m)
|
|
1573
|
-
else bySession.set(m.sid, [m])
|
|
1574
|
-
}
|
|
1904
|
+
const stampTaskAgents = (bySession: Map<string, IndexedMessage[]>) => {
|
|
1575
1905
|
for (const list of bySession.values()) list.sort((a, b) => a.created - b.created)
|
|
1576
1906
|
for (const m of msgIndex.values()) {
|
|
1577
1907
|
if (m.role !== "assistant") continue
|
|
@@ -1665,9 +1995,31 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1665
1995
|
}
|
|
1666
1996
|
}
|
|
1667
1997
|
|
|
1668
|
-
const stampsPass = () => {
|
|
1669
|
-
|
|
1670
|
-
|
|
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)
|
|
1671
2023
|
stampFileLanguages()
|
|
1672
2024
|
}
|
|
1673
2025
|
|
|
@@ -1730,8 +2082,15 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1730
2082
|
emitMerged(st, sid, part.messageID)
|
|
1731
2083
|
}
|
|
1732
2084
|
|
|
1733
|
-
const dropMergedMessage = (st: SessionState, messageID: string) => {
|
|
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
|
|
1734
2091
|
st.merged.delete(messageID)
|
|
2092
|
+
msgIndex.delete(`${sid}:${messageID}`)
|
|
2093
|
+
tombstoned.add(`${sid}:${messageID}`)
|
|
1735
2094
|
}
|
|
1736
2095
|
|
|
1737
2096
|
const dropMergedPart = (st: SessionState, sid: string, messageID: string, partID: string) => {
|
|
@@ -1757,6 +2116,10 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1757
2116
|
clearTimeout(r.initTimer)
|
|
1758
2117
|
r.initTimer = null
|
|
1759
2118
|
}
|
|
2119
|
+
if (r.backoffResetTimer) {
|
|
2120
|
+
clearTimeout(r.backoffResetTimer)
|
|
2121
|
+
r.backoffResetTimer = null
|
|
2122
|
+
}
|
|
1760
2123
|
if (r.reconnectTimer) {
|
|
1761
2124
|
clearTimeout(r.reconnectTimer)
|
|
1762
2125
|
r.reconnectTimer = null
|
|
@@ -1766,8 +2129,28 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1766
2129
|
clearTimeout(r.idleDropTimer)
|
|
1767
2130
|
r.idleDropTimer = null
|
|
1768
2131
|
}
|
|
1769
|
-
|
|
1770
|
-
|
|
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) {
|
|
1771
2154
|
// turn mode may have dropped the socket after the last settle; the
|
|
1772
2155
|
// goodbye still must land — say it over HTTP
|
|
1773
2156
|
void fetch(`${r.url}/api/share/${r.shareId}`, {
|
|
@@ -1786,6 +2169,15 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1786
2169
|
}
|
|
1787
2170
|
}, 1000)
|
|
1788
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)
|
|
1789
2181
|
sessions.delete(st.sid)
|
|
1790
2182
|
try {
|
|
1791
2183
|
chain = chain.then(async () => {
|
|
@@ -1858,6 +2250,11 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
1858
2250
|
if (nextBefore === before) break
|
|
1859
2251
|
before = nextBefore
|
|
1860
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
|
+
}
|
|
1861
2258
|
return out
|
|
1862
2259
|
}
|
|
1863
2260
|
|
|
@@ -2145,8 +2542,13 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2145
2542
|
// runs still land their final status.
|
|
2146
2543
|
if (st.timer) clearTimeout(st.timer)
|
|
2147
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)
|
|
2148
2548
|
chain = chain
|
|
2149
|
-
.then(() =>
|
|
2549
|
+
.then(() => {
|
|
2550
|
+
flush(st)
|
|
2551
|
+
})
|
|
2150
2552
|
.catch((e) => logError("idle flush error", (e as Error)?.message || e))
|
|
2151
2553
|
const epoch = st.statusEpoch
|
|
2152
2554
|
const settle = () => {
|
|
@@ -2155,7 +2557,9 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2155
2557
|
if (st.timer) clearTimeout(st.timer)
|
|
2156
2558
|
st.timer = null
|
|
2157
2559
|
chain = chain
|
|
2158
|
-
.then(() =>
|
|
2560
|
+
.then(() => {
|
|
2561
|
+
flush(st)
|
|
2562
|
+
})
|
|
2159
2563
|
.then(() => {
|
|
2160
2564
|
armSettleDrop(st)
|
|
2161
2565
|
})
|
|
@@ -2195,8 +2599,12 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2195
2599
|
const messageID = p.messageID as string | undefined
|
|
2196
2600
|
if (!messageID) return
|
|
2197
2601
|
if (t === "message.removed") {
|
|
2198
|
-
|
|
2199
|
-
|
|
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) })
|
|
2200
2608
|
} else {
|
|
2201
2609
|
const partID = p.partID as string | undefined
|
|
2202
2610
|
if (!partID) return
|
|
@@ -2326,6 +2734,16 @@ export function createEngine(deps: EngineDeps): Engine {
|
|
|
2326
2734
|
await flushViaHttp(st, r).catch(() => {})
|
|
2327
2735
|
}
|
|
2328
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
|
+
}
|
|
2329
2747
|
if (st.metaTimer) clearTimeout(st.metaTimer)
|
|
2330
2748
|
st.metaTimer = null
|
|
2331
2749
|
await writeBinding(st).catch(() => {})
|