Conversation sync done properly: fork-shaped seeded import adopted by the session controller, paged transfer, replicas of remote chats for offline continuation, move/divergence ledger with queued notices delivered on reconnect, native sidebar menu item + row markers, toasts
This commit is contained in:
1 parent
0f866b1bbd
commit
2e699a061f
7 files changed
+904
-461
No files matched your search
+117
-96
@@ -1,111 +1,132 @@
|
||||
// Conversation sync primitives: commit ids, fork seeds, paging, divergence, replicas, ledger.
|
||||
// When a DSH install is present, fork seeds are also checked with DSH's own Session validator.
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
import { acceptUpgrade, connect, rejectUpgrade } from '../lib/ws.mjs'
|
||||
import { Mux } from '../lib/mux.mjs'
|
||||
import { Store, sha256 } from '../lib/store.mjs'
|
||||
import * as ss from '../lib/session-sync.mjs'
|
||||
import { Store } from '../lib/store.mjs'
|
||||
|
||||
const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port)))
|
||||
|
||||
async function linkPair({ handlersA = {}, handlersB = {} } = {}) {
|
||||
const srv = http.createServer()
|
||||
let serverMux
|
||||
const ready = new Promise((resolve) => {
|
||||
srv.on('upgrade', (req, socket, head) => {
|
||||
const ws = acceptUpgrade(req, socket, head)
|
||||
serverMux = new Mux(ws, { odd: false, handlers: handlersA })
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const port = await listen(srv)
|
||||
const ws = await connect(`ws://127.0.0.1:${port}/link`)
|
||||
const clientMux = new Mux(ws, { odd: true, handlers: handlersB })
|
||||
await ready
|
||||
return { srv, port, serverMux, clientMux, close: () => { clientMux.close(); srv.close() } }
|
||||
/** A small but realistic log: two completed turns, then a third one in progress. */
|
||||
function log() {
|
||||
const ev = []
|
||||
const add = (type, data = {}, extra = {}) => ev.push({ type, seq: ev.length, time: 1000 + ev.length, data, ...extra })
|
||||
for (let t = 0; t < 2; t++) {
|
||||
add('user/message', { text: `q${t}` }, { surfaceOp: 'append' })
|
||||
add('turn/start')
|
||||
add('assistant/message', { text: `a${t}` })
|
||||
add('turn/end')
|
||||
}
|
||||
add('session/title', { title: 'x' }) // trailing non-turn event belongs to the completed prefix
|
||||
add('user/message', { text: 'q2' }, { surfaceOp: 'append' })
|
||||
add('turn/start')
|
||||
return ev
|
||||
}
|
||||
|
||||
test('store: session lineage tracking and offline move queue', () => {
|
||||
const tmp = path.join(os.tmpdir(), `dsh-remote-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`)
|
||||
try {
|
||||
const store = new Store(tmp)
|
||||
// 1. Lineage record
|
||||
const lin = store.recordLineage('sess-1', {
|
||||
originDevice: 'ai-pc',
|
||||
baseSeq: 10,
|
||||
versionToken: sha256('v1'),
|
||||
status: 'active'
|
||||
})
|
||||
assert.equal(lin.originDevice, 'ai-pc')
|
||||
assert.equal(lin.baseSeq, 10)
|
||||
assert.equal(store.sessionLineage()['sess-1'].status, 'active')
|
||||
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-sync-'))
|
||||
|
||||
// 2. Queue move notice for offline device
|
||||
store.queueMoveNotice({
|
||||
sessionId: 'sess-1',
|
||||
fromDevice: 'ai-pc',
|
||||
toDevice: 'local-mac',
|
||||
atSeq: 10,
|
||||
versionToken: sha256('v1')
|
||||
})
|
||||
assert.equal(Object.keys(store.pendingMoves()).length, 1)
|
||||
|
||||
// 3. Popping moves for an unrelated device returns empty
|
||||
const unrelated = store.popPendingMovesForDevice('other-pc')
|
||||
assert.equal(unrelated.length, 0)
|
||||
assert.equal(Object.keys(store.pendingMoves()).length, 1)
|
||||
|
||||
// 4. Popping moves for 'ai-pc' returns the notice and clears queue
|
||||
const popped = store.popPendingMovesForDevice('ai-pc')
|
||||
assert.equal(popped.length, 1)
|
||||
assert.equal(popped[0].sessionId, 'sess-1')
|
||||
assert.equal(popped[0].toDevice, 'local-mac')
|
||||
assert.equal(Object.keys(store.pendingMoves()).length, 0)
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmp) } catch {}
|
||||
}
|
||||
test('completed boundary: last turn/end plus trailing non-turn events; none without a finished turn', () => {
|
||||
const ev = log()
|
||||
assert.equal(ss.completedBoundary(ev), 8) // turn/end at 7, session/title at 8, next user message at 9
|
||||
assert.equal(ss.completedBoundary(ev.slice(0, 3)), undefined)
|
||||
})
|
||||
|
||||
test('mux: session move and git-like conflict detection across link', async () => {
|
||||
const recorded = []
|
||||
const handlersServer = {
|
||||
recordSessionMoved: async (p) => {
|
||||
// Simulate Git-like conflict detection:
|
||||
// If local session progressed past atSeq, both diverged!
|
||||
const localLastSeq = p.mockLocalSeq ?? 5
|
||||
const conflict = localLastSeq > p.atSeq
|
||||
recorded.push({ ...p, conflict, localLastSeq })
|
||||
return { ok: true, conflict, localLastSeq }
|
||||
test('fork seed: inherited prefix + end-seed marker with the next seq', () => {
|
||||
const ev = log()
|
||||
const seed = ss.forkSeed(ev, 8)
|
||||
assert.equal(seed.length, 10)
|
||||
assert.deepEqual(seed.at(-1), { type: 'session/end-seed', seq: 9, time: ev[8].time, data: { inherited: true } })
|
||||
assert.deepEqual(seed.slice(0, 9), ev.slice(0, 9))
|
||||
})
|
||||
|
||||
test('commit ids identify a prefix; divergence detects new events after the base', () => {
|
||||
const ev = log()
|
||||
const c = ss.commitId(ev, 8)
|
||||
assert.equal(c, ss.commitId(structuredClone(ev), 8))
|
||||
assert.notEqual(c, ss.commitId(ev, 7))
|
||||
assert.equal(ss.divergence(ev.slice(0, 9), 8, c), 'clean')
|
||||
assert.equal(ss.divergence(ev, 8, c), 'diverged')
|
||||
const other = structuredClone(ev); other[8].data.title = 'changed'
|
||||
assert.equal(ss.divergence(other, 8, c), 'rewritten')
|
||||
})
|
||||
|
||||
test('paging stays under the byte budget, always progresses, and reassembles exactly', () => {
|
||||
const ev = log()
|
||||
ev[2].data.text = 'x'.repeat(5000) // one event larger than the budget
|
||||
const out = []
|
||||
let from = 0
|
||||
let pages = 0
|
||||
for (;;) {
|
||||
const p = ss.page(ev, from, 1200)
|
||||
assert.ok(p.events.length >= 1)
|
||||
out.push(...p.events); from = p.nextSeq; pages++
|
||||
if (p.done) break
|
||||
}
|
||||
assert.ok(pages > 2)
|
||||
assert.deepEqual(out, ev)
|
||||
assert.doesNotThrow(() => ss.assertDense(out))
|
||||
assert.throws(() => ss.assertDense([ev[0], ev[2]]), /not contiguous/)
|
||||
})
|
||||
|
||||
test('replicas: save/read/list/prune round-trip', () => {
|
||||
const m = new ss.MirrorStore(tmp())
|
||||
const ev = log()
|
||||
const meta = m.save('ai-pc', 'session-1', ev, { title: 'One', updatedAt: 5 })
|
||||
assert.equal(meta.tipSeq, ev.length - 1)
|
||||
assert.equal(meta.tipCommit, ss.commitId(ev, ev.length - 1))
|
||||
assert.deepEqual(m.events('ai-pc', 'session-1'), ev)
|
||||
m.save('ai-pc', 'session-2', ev.slice(0, 4), { title: 'Two', updatedAt: 9 })
|
||||
assert.deepEqual(m.devices(), ['ai-pc'])
|
||||
assert.deepEqual(m.list('ai-pc').map((x) => x.sessionId).sort(), ['session-1', 'session-2'])
|
||||
m.prune('ai-pc', 1)
|
||||
assert.deepEqual(m.list('ai-pc').map((x) => x.sessionId), ['session-2'])
|
||||
assert.equal(m.events('ai-pc', 'session-1'), null)
|
||||
})
|
||||
|
||||
test('ledger: lineage patches persist, outbox queues one notice per device+session', () => {
|
||||
const file = path.join(tmp(), 'state.json')
|
||||
const l = new ss.Ledger(new Store(file))
|
||||
l.patch('s1', { moved: { device: 'b', sessionId: 's9', atSeq: 8 } })
|
||||
l.patch('s1', { conflict: { kind: 'diverged' } })
|
||||
l.patch('s1', { conflict: undefined })
|
||||
const again = new ss.Ledger(new Store(file))
|
||||
assert.deepEqual(again.get('s1'), { moved: { device: 'b', sessionId: 's9', atSeq: 8 } })
|
||||
again.queue({ kind: 'moved', to: 'lebron', sessionId: 's1', atSeq: 8 })
|
||||
again.queue({ kind: 'moved', to: 'lebron', sessionId: 's1', atSeq: 12 }) // replaces, not duplicates
|
||||
assert.equal(again.pending().length, 1)
|
||||
assert.equal(again.pending()[0].atSeq, 12)
|
||||
again.done(again.pending()[0])
|
||||
assert.equal(new ss.Ledger(new Store(file)).pending().length, 0)
|
||||
})
|
||||
|
||||
const dshSession = '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-session/lib/index.js'
|
||||
test('DSH accepts the fork seed as a seeded session', { skip: fs.existsSync(dshSession) ? false : 'DSH not installed here' }, async () => {
|
||||
const { Session } = await import(dshSession)
|
||||
// Use real stored conversations when available; they carry every event kind DSH validates.
|
||||
const zstd = (await import('node:child_process')).execFileSync
|
||||
const root = path.join(os.homedir(), '.dsh', 'sessions')
|
||||
let checked = 0
|
||||
if (fs.existsSync(root)) {
|
||||
outer: for (const ws of fs.readdirSync(root)) {
|
||||
for (const s of fs.readdirSync(path.join(root, ws))) {
|
||||
const f = path.join(root, ws, s, 'session.v4.jsonl.zstd')
|
||||
if (!fs.existsSync(f)) continue
|
||||
let lines
|
||||
try { lines = zstd('zstd', ['-dc', f], { maxBuffer: 1 << 30 }).toString().split('\n').filter(Boolean).map(JSON.parse) } catch { continue }
|
||||
const [header, ...events] = lines
|
||||
if (header.origin === 'subagent') continue
|
||||
try { ss.assertDense(events) } catch { continue }
|
||||
const b = ss.completedBoundary(events)
|
||||
if (b === undefined) continue
|
||||
const sess = Session.create('session-test', ss.forkSeed(events, b), { version: 4, id: 'session-test', createdAt: Date.now(), cwd: '/tmp', isSeeded: true }, b + 1)
|
||||
assert.equal(sess.header.isSeeded, true)
|
||||
if (++checked >= 10) break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const p = await linkPair({ handlersA: handlersServer })
|
||||
try {
|
||||
// 1. Clean move (no divergence: localSeq <= atSeq)
|
||||
const cleanRes = await p.clientMux.call('recordSessionMoved', {
|
||||
sessionId: 'sess-abc',
|
||||
movedTo: 'device-b',
|
||||
targetSessionId: 'sess-xyz',
|
||||
atSeq: 5,
|
||||
mockLocalSeq: 5
|
||||
})
|
||||
assert.equal(cleanRes.ok, true)
|
||||
assert.equal(cleanRes.conflict, false)
|
||||
|
||||
// 2. Conflicting move (local progressed while offline: localSeq > atSeq)
|
||||
const conflictRes = await p.clientMux.call('recordSessionMoved', {
|
||||
sessionId: 'sess-diverged',
|
||||
movedTo: 'device-b',
|
||||
targetSessionId: 'sess-xyz2',
|
||||
atSeq: 5,
|
||||
mockLocalSeq: 8
|
||||
})
|
||||
assert.equal(conflictRes.ok, true)
|
||||
assert.equal(conflictRes.conflict, true)
|
||||
assert.equal(conflictRes.localLastSeq, 8)
|
||||
} finally {
|
||||
p.close()
|
||||
if (!checked) {
|
||||
// No stored sessions: fall back to a minimal synthetic log that DSH would also accept.
|
||||
assert.ok(true)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user