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
@@ -25,15 +25,19 @@
|
||||
// Upgrade routes:
|
||||
// /dsh-remote/link → slave → master link (device id + key headers)
|
||||
// /r/<device>/api/remote.mux → DSH Remote stream WebSocket of another device
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { acceptUpgrade, connect, rejectUpgrade } from './lib/ws.mjs'
|
||||
import { Mux } from './lib/mux.mjs'
|
||||
import { Store, randomId, setProfileDir, slug, sha256 } from './lib/store.mjs'
|
||||
import { Store, randomId, setProfileDir, slug, stateDir } from './lib/store.mjs'
|
||||
import { CookieJar } from './lib/auth-cookie.mjs'
|
||||
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
|
||||
import * as sync from './lib/sync.mjs'
|
||||
import * as ops from './lib/agent-ops.mjs'
|
||||
import { createEdgeAuth } from './lib/edge-auth.mjs'
|
||||
import * as ss from './lib/session-sync.mjs'
|
||||
|
||||
export const name = 'dsh-remote'
|
||||
export const inject = {
|
||||
@@ -52,8 +56,8 @@ const MAX_BODY = 256 * 1024
|
||||
const PREFIX = '/r/'
|
||||
const LINK_PATH = '/dsh-remote/link'
|
||||
const MUX_PATH = '/api/remote.mux'
|
||||
/** Link methods that act on a device on behalf of an agent or session sync. */
|
||||
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession', 'exportSession', 'importSession', 'recordSessionMoved'])
|
||||
/** Link methods that act on a device on behalf of an agent. */
|
||||
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession'])
|
||||
/** Pseudo-secret name: opting into it ships the master's forge (git) logins. */
|
||||
const FORGE_SECRET = 'FORGE_CREDENTIALS'
|
||||
/** Session titles must not carry the machine name ("ai-pc: fix tests" → "fix tests"): the device is shown separately. */
|
||||
@@ -203,136 +207,13 @@ export function apply(ctx, config = {}) {
|
||||
if (!offerSecretsFor(dev).includes(FORGE_SECRET)) throw httpErr(403, `${FORGE_SECRET} not offered to this device`)
|
||||
return { logins: ops.readForgeLogins() }
|
||||
},
|
||||
// ---- seamless conversation sync & version control ----
|
||||
exportSession: async (p, mux) => {
|
||||
const sc = svc(ctx, 'sessionController')
|
||||
if (!sc) throw httpErr(503, 'session controller unavailable')
|
||||
let ins = null
|
||||
try {
|
||||
ins = await sc.inspect(p.sessionId)
|
||||
} catch (err) {
|
||||
throw httpErr(404, `session "${p.sessionId}" not found: ${err.message}`)
|
||||
}
|
||||
const events = ins.events || []
|
||||
const lastSeq = events.at(-1)?.seq ?? -1
|
||||
const versionToken = store.sessionLineage()?.[p.sessionId]?.versionToken ||
|
||||
sha256(`${p.sessionId}:${lastSeq}:${events.length}`)
|
||||
return {
|
||||
sessionId: p.sessionId,
|
||||
meta: ins.meta || {},
|
||||
events,
|
||||
lastSeq,
|
||||
versionToken,
|
||||
title: stripDevicePrefix(ins.meta?.title ?? ins.meta?.projections?.values?.title, [self.id, self.name]) || ins.meta?.title || 'Session',
|
||||
cwd: ins.meta?.cwd,
|
||||
workspaceId: ins.meta?.workspaceId,
|
||||
}
|
||||
},
|
||||
importSession: async (p, mux) => {
|
||||
const sc = svc(ctx, 'sessionController')
|
||||
const sp = svc(ctx, 'sessionPersistence')
|
||||
if (!sc) throw httpErr(503, 'session controller unavailable')
|
||||
|
||||
const targetSessionId = `session-${randomId(12)}`
|
||||
const title = p.title || p.meta?.title || 'Synced session'
|
||||
const cwd = p.cwd || p.meta?.cwd
|
||||
const workspaceId = p.workspaceId || p.meta?.workspaceId
|
||||
|
||||
let createdViaSp = false
|
||||
if (sp && p.events && p.events.length > 0) {
|
||||
try {
|
||||
const header = {
|
||||
id: targetSessionId,
|
||||
createdAt: Date.now(),
|
||||
cwd: cwd || process.cwd(),
|
||||
workspaceId,
|
||||
title,
|
||||
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||||
originSessionId: p.sessionId,
|
||||
}
|
||||
const handle = await sp.create(header)
|
||||
await handle.append(p.events)
|
||||
await handle.flush()
|
||||
await handle.close()
|
||||
createdViaSp = true
|
||||
} catch (e) {
|
||||
log.warn?.(`dsh-remote: sp.create import failed: ${e.message}, falling back to sc.create`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!createdViaSp) {
|
||||
const res = await sc.create({ cwd, workspaceId })
|
||||
const createdId = res.sessionId
|
||||
try {
|
||||
await sc.rename({ sessionId: createdId, title })
|
||||
} catch {}
|
||||
store.recordLineage(createdId, {
|
||||
originSessionId: p.sessionId,
|
||||
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||||
baseSeq: p.lastSeq ?? 0,
|
||||
versionToken: p.versionToken,
|
||||
syncedAt: Date.now(),
|
||||
status: 'active',
|
||||
})
|
||||
return { sessionId: createdId, ok: true }
|
||||
}
|
||||
|
||||
store.recordLineage(targetSessionId, {
|
||||
originSessionId: p.sessionId,
|
||||
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||||
baseSeq: p.lastSeq ?? 0,
|
||||
versionToken: p.versionToken,
|
||||
syncedAt: Date.now(),
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
return { sessionId: targetSessionId, ok: true }
|
||||
},
|
||||
recordSessionMoved: async (p, mux) => {
|
||||
const sc = svc(ctx, 'sessionController')
|
||||
let localLastSeq = -1
|
||||
let conflict = false
|
||||
if (sc) {
|
||||
try {
|
||||
const ins = await sc.inspect(p.sessionId)
|
||||
const events = ins.events || []
|
||||
localLastSeq = events.at(-1)?.seq ?? -1
|
||||
if (localLastSeq > (p.atSeq ?? -1)) {
|
||||
conflict = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
store.recordLineage(p.sessionId, {
|
||||
movedTo: p.movedTo,
|
||||
targetSessionId: p.targetSessionId,
|
||||
atSeq: p.atSeq,
|
||||
localLastSeq,
|
||||
versionToken: p.versionToken,
|
||||
conflict,
|
||||
status: conflict ? 'conflict' : 'moved',
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
notify({
|
||||
type: 'session-conflict',
|
||||
sessionId: p.sessionId,
|
||||
movedTo: p.movedTo,
|
||||
atSeq: p.atSeq,
|
||||
localLastSeq,
|
||||
title: `Sync conflict on ${p.sessionId}: moved to ${p.movedTo} at seq ${p.atSeq}, modified locally up to seq ${localLastSeq}`,
|
||||
})
|
||||
} else {
|
||||
notify({
|
||||
type: 'session-moved',
|
||||
sessionId: p.sessionId,
|
||||
movedTo: p.movedTo,
|
||||
title: `Session ${p.sessionId} moved to ${p.movedTo} (synced to prevent conflicts)`,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, conflict, localLastSeq }
|
||||
},
|
||||
// ---- conversation sync (see lib/session-sync.mjs) ----
|
||||
syncPage: async (p) => syncEngine.servePage(p),
|
||||
importBegin: async (p) => syncEngine.importBegin(p),
|
||||
importChunk: async (p) => syncEngine.importChunk(p),
|
||||
importCommit: async (p) => syncEngine.importCommit(p),
|
||||
sessionMoved: async (p) => syncEngine.onMoved(p),
|
||||
sessionDiverged: async (p) => syncEngine.onDiverged(p),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,7 +281,7 @@ export function apply(ctx, config = {}) {
|
||||
running: s.running,
|
||||
cwd: s.cwd,
|
||||
title: stripDevicePrefix(s.projections?.values?.title, [self.id, self.name]) || (s.projections?.values?.title ?? null),
|
||||
lineage: store.sessionLineage()[s.sessionId] || null,
|
||||
lineage: ledger.get(s.sessionId),
|
||||
})),
|
||||
workspaces,
|
||||
}
|
||||
@@ -419,6 +300,330 @@ export function apply(ctx, config = {}) {
|
||||
return { sessionId: r.sessionId }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ conversation sync
|
||||
// Git-like hand-over of a conversation between devices (see lib/session-sync.mjs).
|
||||
const syncCfg = { mirrorRecent: 10, mirrorIntervalMs: 60000, watchIntervalMs: 20000, ...(config.sessionSync || {}) }
|
||||
const ledger = new ss.Ledger(store)
|
||||
const mirrors = new ss.MirrorStore(path.join(stateDir(), 'mirrors'))
|
||||
const syncEngine = createSyncEngine()
|
||||
|
||||
function createSyncEngine() {
|
||||
const readCache = new Map() // sessionId -> { at, events, cwd }
|
||||
const staged = new Map() // importId -> { at, meta, events }
|
||||
let mirroring = false
|
||||
let delivering = false
|
||||
|
||||
const sc = () => {
|
||||
const c = svc(ctx, 'sessionController')
|
||||
if (!c) throw httpErr(503, 'session controller unavailable')
|
||||
return c
|
||||
}
|
||||
|
||||
/** Full local log (cached briefly so paging a big session reads it once). */
|
||||
async function readLocal(sessionId) {
|
||||
const hit = readCache.get(sessionId)
|
||||
if (hit && Date.now() - hit.at < 30000) return hit
|
||||
let ins
|
||||
try { ins = await sc().inspect(sessionId) } catch (e) { throw httpErr(404, `session ${sessionId} not found on ${self.name}: ${e.message || e}`) }
|
||||
const v = { at: Date.now(), events: ins.events || [], cwd: ins.meta?.cwd }
|
||||
readCache.set(sessionId, v)
|
||||
for (const [k, x] of readCache) if (Date.now() - x.at > 30000) readCache.delete(k)
|
||||
return v
|
||||
}
|
||||
|
||||
/** Link method: one page of a local session, optionally continuing a replica the caller holds. */
|
||||
async function servePage({ sessionId, fromSeq = 0, knownSeq, knownCommit }) {
|
||||
const { events, cwd } = await readLocal(sessionId)
|
||||
let from = Number(fromSeq) || 0
|
||||
let reset = false
|
||||
if (knownSeq !== undefined && knownSeq >= 0 && ss.commitId(events, knownSeq) !== knownCommit) { reset = true; from = 0 }
|
||||
const pg = ss.page(events, from)
|
||||
const tip = ss.tipSeq(events)
|
||||
return { sessionId, cwd, reset, events: pg.events, nextSeq: pg.nextSeq, done: pg.done, tipSeq: tip, tipCommit: tip >= 0 ? ss.commitId(events, tip) : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a remote session (incrementally on top of the local replica, if any) and refresh the
|
||||
* replica. Falls back to the replica when the device is unreachable.
|
||||
*/
|
||||
async function fetchRemote(device, sessionId, info = {}) {
|
||||
const meta = mirrors.meta(device, sessionId)
|
||||
let events = meta ? mirrors.events(device, sessionId) : null
|
||||
try {
|
||||
if (!routeTo(device)) throw httpErr(503, `device ${device} is offline`)
|
||||
let from = 0
|
||||
let known = {}
|
||||
if (events && events.length && meta?.tipCommit) { from = meta.tipSeq + 1; known = { knownSeq: meta.tipSeq, knownCommit: meta.tipCommit } } else events = []
|
||||
let cwd = meta?.cwd
|
||||
for (;;) {
|
||||
const pg = await callDevice(device, 'syncPage', { sessionId, fromSeq: from, ...known }, 60000)
|
||||
if (pg.reset) { events = []; known = {} }
|
||||
events.push(...pg.events)
|
||||
cwd = pg.cwd ?? cwd
|
||||
from = pg.nextSeq
|
||||
if (pg.done) break
|
||||
known = {}
|
||||
}
|
||||
ss.assertDense(events)
|
||||
const saved = mirrors.save(device, sessionId, events, { ...meta, ...info, cwd, deviceName: info.deviceName || meta?.deviceName })
|
||||
return { events, cwd, fromMirror: false, syncedAt: saved.syncedAt }
|
||||
} catch (e) {
|
||||
if (events && events.length) return { events, cwd: meta.cwd, fromMirror: true, syncedAt: meta.syncedAt, offlineReason: String(e.message || e) }
|
||||
throw e.status ? e : httpErr(503, String(e.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep replicas of other devices' recent sessions so they can be continued while offline. */
|
||||
async function refreshMirrors() {
|
||||
if (mirroring || syncCfg.mirrorRecent <= 0) return
|
||||
mirroring = true
|
||||
try {
|
||||
for (const d of devicesView()) {
|
||||
if (d.self || !d.online || d.revoked) continue
|
||||
let list
|
||||
try { list = await callDevice(d.id, 'sessions', {}, 15000) } catch { continue }
|
||||
const recent = (list.items || []).slice(0, syncCfg.mirrorRecent)
|
||||
for (const s of recent) {
|
||||
const m = mirrors.meta(d.id, s.sessionId)
|
||||
if (m && m.updatedAt >= s.updatedAt) continue
|
||||
try {
|
||||
await fetchRemote(d.id, s.sessionId, { title: s.title, updatedAt: s.updatedAt, cwd: s.cwd, deviceName: d.name })
|
||||
} catch (e) { log.debug?.(`dsh-remote: mirror ${d.id}/${s.sessionId}: ${e.message}`) }
|
||||
}
|
||||
mirrors.prune(d.id, Math.max(syncCfg.mirrorRecent * 2, 20))
|
||||
}
|
||||
} finally { mirroring = false }
|
||||
}
|
||||
|
||||
/** Choose where an imported session lives on this device. */
|
||||
function placeLocally({ workspaceId, cwd }) {
|
||||
const wr = svc(ctx, 'workspaceRegistry')
|
||||
const workspaces = wr ? wr.list() : []
|
||||
if (workspaceId) {
|
||||
const w = workspaces.find((x) => x.id === workspaceId)
|
||||
if (!w) throw httpErr(404, `workspace ${workspaceId} not found on ${self.name}`)
|
||||
return { cwd: w.path, workspaceId: w.id }
|
||||
}
|
||||
const exact = cwd && workspaces.find((x) => x.path === cwd)
|
||||
if (exact) return { cwd: exact.path, workspaceId: exact.id }
|
||||
const base = cwd ? path.basename(cwd) : ''
|
||||
const sameName = base && workspaces.find((x) => path.basename(x.path) === base)
|
||||
if (sameName) return { cwd: sameName.path, workspaceId: sameName.id }
|
||||
if (cwd && path.isAbsolute(cwd) && fs.existsSync(cwd)) return { cwd }
|
||||
return { cwd: os.homedir() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise a conversation prefix on THIS device as a new seeded session — the same log
|
||||
* shape DSH writes for a fork — then let the session controller adopt it, which composes the
|
||||
* agent (tools, preset) and attaches it to the workspace exactly like a native session.
|
||||
*/
|
||||
async function importLocal({ events, baseSeq, origin, workspaceId, cwd }) {
|
||||
ss.assertDense(events)
|
||||
if (!(baseSeq >= 0 && baseSeq < events.length)) throw httpErr(400, 'invalid base seq')
|
||||
const sp = svc(ctx, 'sessionPersistence')
|
||||
if (!sp) throw httpErr(503, 'session persistence unavailable on this device')
|
||||
const where = placeLocally({ workspaceId, cwd })
|
||||
const sessionId = `session-${crypto.randomUUID()}`
|
||||
const seed = ss.forkSeed(events, baseSeq)
|
||||
const header = { version: 4, id: sessionId, createdAt: Date.now(), cwd: where.cwd, isSeeded: true }
|
||||
const handle = await sp.create(header, { inheritedEventCount: baseSeq + 1 })
|
||||
try {
|
||||
await handle.append(seed)
|
||||
await handle.flush?.()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await sc().create(where.workspaceId ? { sessionId, workspaceId: where.workspaceId } : { sessionId, cwd: where.cwd })
|
||||
ledger.patch(sessionId, { origin: { ...origin, baseSeq, baseCommit: ss.commitId(events, baseSeq), importedAt: Date.now() } })
|
||||
notify({ type: 'sessions' })
|
||||
return { sessionId, cwd: where.cwd, workspaceId: where.workspaceId || null }
|
||||
}
|
||||
|
||||
// Link methods for importing onto another device in pages.
|
||||
function importBegin(meta) {
|
||||
for (const [k, v] of staged) if (Date.now() - v.at > 10 * 60000) staged.delete(k)
|
||||
const id = randomId(12)
|
||||
staged.set(id, { at: Date.now(), meta, events: [] })
|
||||
return { importId: id }
|
||||
}
|
||||
function importChunk({ importId, events }) {
|
||||
const st = staged.get(importId)
|
||||
if (!st) throw httpErr(404, 'unknown import (expired?)')
|
||||
st.events.push(...events)
|
||||
st.at = Date.now()
|
||||
return { received: st.events.length }
|
||||
}
|
||||
async function importCommit({ importId }) {
|
||||
const st = staged.get(importId)
|
||||
if (!st) throw httpErr(404, 'unknown import (expired?)')
|
||||
staged.delete(importId)
|
||||
return importLocal({ ...st.meta, events: st.events })
|
||||
}
|
||||
|
||||
async function importOn(device, payload) {
|
||||
if (device === self.id) return importLocal(payload)
|
||||
const { events, ...meta } = payload
|
||||
const { importId } = await callDevice(device, 'importBegin', meta, 20000)
|
||||
for (let from = 0; from < events.length;) {
|
||||
const pg = ss.page(events, from)
|
||||
await callDevice(device, 'importChunk', { importId, events: pg.events }, 60000)
|
||||
from = pg.nextSeq
|
||||
}
|
||||
return callDevice(device, 'importCommit', { importId }, 120000)
|
||||
}
|
||||
|
||||
/** Deliver a notice now, or queue it for the moment the device is reachable again. */
|
||||
async function send(notice) {
|
||||
const method = notice.kind === 'diverged' ? 'sessionDiverged' : 'sessionMoved'
|
||||
if (notice.to === self.id) return { delivered: true, result: await handlers[method](notice) }
|
||||
if (routeTo(notice.to)) {
|
||||
try {
|
||||
const result = await callDevice(notice.to, method, notice, 20000)
|
||||
return { delivered: true, result }
|
||||
} catch (e) { log.warn?.(`dsh-remote: ${method} → ${notice.to} failed, queued: ${e.message}`) }
|
||||
}
|
||||
ledger.queue(notice)
|
||||
return { delivered: false }
|
||||
}
|
||||
|
||||
async function deliverOutbox() {
|
||||
if (delivering) return
|
||||
delivering = true
|
||||
try {
|
||||
for (const n of ledger.pending()) {
|
||||
if (n.to !== self.id && !routeTo(n.to)) continue
|
||||
const method = n.kind === 'diverged' ? 'sessionDiverged' : 'sessionMoved'
|
||||
try {
|
||||
const result = n.to === self.id ? await handlers[method](n) : await callDevice(n.to, method, n, 20000)
|
||||
ledger.done(n)
|
||||
if (n.kind === 'moved') afterMoveAck(n, result)
|
||||
notify({ type: 'session-sync', delivered: n })
|
||||
} catch (e) { log.warn?.(`dsh-remote: queued ${method} → ${n.to}: ${e.message}`) }
|
||||
}
|
||||
} finally { delivering = false }
|
||||
}
|
||||
|
||||
/** The source answered a move notice; if it had diverged, flag our copy too. */
|
||||
function afterMoveAck(notice, result) {
|
||||
if (!result || result.state === 'clean') return
|
||||
const copy = notice.movedTo
|
||||
if (copy.device !== self.id) return // the conflict notice goes to that device from the source
|
||||
ledger.patch(copy.sessionId, { conflict: { kind: result.state, otherTip: result.localTip, atSeq: notice.atSeq, detectedAt: Date.now(), other: { device: notice.to, sessionId: notice.sessionId } } })
|
||||
notify({ type: 'session-conflict', device: self.id, sessionId: copy.sessionId, title: notice.title, other: { device: notice.to, deviceName: deviceName(notice.to), sessionId: notice.sessionId } })
|
||||
}
|
||||
|
||||
/** Link method on the source device: "your session now continues elsewhere". */
|
||||
async function onMoved(n) {
|
||||
let events = []
|
||||
try { readCache.delete(n.sessionId); events = (await readLocal(n.sessionId)).events } catch { /* deleted here */ }
|
||||
const state = events.length ? ss.divergence(events, n.atSeq, n.atCommit) : 'clean'
|
||||
const localTip = ss.tipSeq(events)
|
||||
ledger.patch(n.sessionId, {
|
||||
moved: { device: n.movedTo.device, sessionId: n.movedTo.sessionId, atSeq: n.atSeq, atCommit: n.atCommit, at: n.at, by: n.by },
|
||||
conflict: state === 'clean' ? undefined : { kind: state, localTip, atSeq: n.atSeq, detectedAt: Date.now(), other: n.movedTo },
|
||||
})
|
||||
notify({ type: state === 'clean' ? 'session-moved' : 'session-conflict', device: self.id, sessionId: n.sessionId, title: n.title, other: { ...n.movedTo, deviceName: deviceName(n.movedTo.device) } })
|
||||
if (state !== 'clean') {
|
||||
// Tell the device holding the moved copy as well (now or as soon as it is reachable).
|
||||
await send({ kind: 'diverged', to: n.movedTo.device, sessionId: n.movedTo.sessionId, other: { device: self.id, sessionId: n.sessionId }, state, localTip, atSeq: n.atSeq, title: n.title })
|
||||
}
|
||||
return { state, localTip }
|
||||
}
|
||||
|
||||
/** Link method on the copy's device: the other branch got new events after the base. */
|
||||
function onDiverged(n) {
|
||||
ledger.patch(n.sessionId, { conflict: { kind: n.state || 'diverged', otherTip: n.localTip, atSeq: n.atSeq, detectedAt: Date.now(), other: n.other } })
|
||||
notify({ type: 'session-conflict', device: self.id, sessionId: n.sessionId, title: n.title, other: { ...n.other, deviceName: deviceName(n.other.device) } })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** A moved-away session that gets new turns here has diverged from its continuation. */
|
||||
async function watchMoved() {
|
||||
const entries = Object.entries(ledger.all()).filter(([, l]) => l.moved && !l.conflict)
|
||||
if (!entries.length) return
|
||||
let items = []
|
||||
try { items = (await listLocalSessions()).items } catch { return }
|
||||
for (const [sessionId, l] of entries) {
|
||||
const it = items.find((x) => x.sessionId === sessionId)
|
||||
if (!it || !(it.updatedAt > l.moved.at)) continue
|
||||
readCache.delete(sessionId)
|
||||
let events
|
||||
try { events = (await readLocal(sessionId)).events } catch { continue }
|
||||
const state = ss.divergence(events, l.moved.atSeq, l.moved.atCommit)
|
||||
if (state === 'clean') continue
|
||||
const other = { device: l.moved.device, sessionId: l.moved.sessionId }
|
||||
ledger.patch(sessionId, { conflict: { kind: state, localTip: ss.tipSeq(events), atSeq: l.moved.atSeq, detectedAt: Date.now(), other } })
|
||||
notify({ type: 'session-conflict', device: self.id, sessionId, title: it.title, other: { ...other, deviceName: deviceName(other.device) } })
|
||||
await send({ kind: 'diverged', to: other.device, sessionId: other.sessionId, other: { device: self.id, sessionId }, state, localTip: ss.tipSeq(events), atSeq: l.moved.atSeq, title: it.title })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue `sessionId` of `sourceDevice` on `targetDevice`: copy the conversation through its
|
||||
* last completed turn, then tell the source (now, or queued until it is reachable).
|
||||
*/
|
||||
async function continueSession({ sourceDevice, sessionId, targetDevice, workspaceId, title }) {
|
||||
const src = sourceDevice === self.id
|
||||
? { ...(await readLocal(sessionId)), fromMirror: false }
|
||||
: await fetchRemote(sourceDevice, sessionId)
|
||||
const events = src.events
|
||||
ss.assertDense(events)
|
||||
const baseSeq = ss.completedBoundary(events)
|
||||
if (baseSeq === undefined) throw httpErr(409, 'this conversation has no completed turn yet — wait for the first reply to finish')
|
||||
const baseCommit = ss.commitId(events, baseSeq)
|
||||
const prior = sourceDevice === self.id ? ledger.get(sessionId) : null
|
||||
const name = ss.shortTitle(title || mirrors.meta(sourceDevice, sessionId)?.title)
|
||||
const imported = await importOn(targetDevice, {
|
||||
events: events.slice(0, baseSeq + 1), baseSeq, workspaceId, cwd: src.cwd,
|
||||
origin: { device: sourceDevice, deviceName: deviceName(sourceDevice), sessionId, title: name, fromMirror: !!src.fromMirror },
|
||||
})
|
||||
const notice = {
|
||||
kind: 'moved', to: sourceDevice, sessionId, title: name,
|
||||
movedTo: { device: targetDevice, sessionId: imported.sessionId },
|
||||
atSeq: baseSeq, atCommit: baseCommit, at: Date.now(), by: self.id,
|
||||
}
|
||||
const sent = await send(notice)
|
||||
if (sent.delivered) afterMoveAck(notice, sent.result)
|
||||
notify({ type: 'sessions' })
|
||||
return {
|
||||
device: targetDevice, sessionId: imported.sessionId,
|
||||
url: targetDevice === self.id ? `./#/session/${imported.sessionId}` : `./r/${targetDevice}/#/session/${imported.sessionId}`,
|
||||
baseSeq, tipSeq: ss.tipSeq(events), partialTurnLeftBehind: ss.tipSeq(events) > baseSeq,
|
||||
fromMirror: !!src.fromMirror, syncedAt: src.syncedAt || null, offlineReason: src.offlineReason || null,
|
||||
sourceNotified: sent.delivered, conflict: sent.result?.state && sent.result.state !== 'clean' ? sent.result.state : null,
|
||||
previouslyMovedTo: prior?.moved || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Sessions of offline devices, served from replicas. */
|
||||
function offlineSessions(device) {
|
||||
return mirrors.list(device)
|
||||
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||
.map((m) => ({ sessionId: m.sessionId, title: m.title || null, updatedAt: m.updatedAt || m.syncedAt, cwd: m.cwd, running: false, replica: { syncedAt: m.syncedAt, tipSeq: m.tipSeq } }))
|
||||
}
|
||||
|
||||
const deviceName = (id) => devicesView().find((d) => d.id === id)?.name || mirrors.list(id)[0]?.deviceName || id
|
||||
|
||||
// Timers: replicas, divergence watch, and a retry for queued notices.
|
||||
const tMirror = setInterval(() => { refreshMirrors().catch(() => {}) }, syncCfg.mirrorIntervalMs)
|
||||
const tWatch = setInterval(() => { watchMoved().catch(() => {}); deliverOutbox().catch(() => {}) }, syncCfg.watchIntervalMs)
|
||||
const tFirst = setTimeout(() => { refreshMirrors().catch(() => {}) }, 15000)
|
||||
for (const t of [tMirror, tWatch, tFirst]) t.unref?.()
|
||||
ctx.effect(() => () => { clearInterval(tMirror); clearInterval(tWatch); clearTimeout(tFirst) }, 'dsh-remote: session sync timers')
|
||||
|
||||
return {
|
||||
servePage, importBegin, importChunk, importCommit, onMoved, onDiverged, continueSession,
|
||||
offlineSessions, refreshMirrors, deliverOutbox, deviceName,
|
||||
replicaDevices: () => mirrors.devices(),
|
||||
lineage: (id) => ledger.get(id),
|
||||
/** A device (re)appeared: deliver its queued notices right away. */
|
||||
deviceOnline: () => { setTimeout(() => deliverOutbox().catch(() => {}), 500) },
|
||||
resolve(sessionId) { ledger.patch(sessionId, { conflict: undefined }); notify({ type: 'sessions' }); return { ok: true } },
|
||||
state: () => ({ lineage: ledger.all(), outbox: ledger.pending() }),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ device views
|
||||
function devicesView() {
|
||||
const out = [{ id: self.id, name: self.name, role, self: true, online: true }]
|
||||
@@ -473,9 +678,16 @@ export function apply(ctx, config = {}) {
|
||||
async function allSessions() {
|
||||
const devices = devicesView().filter((d) => d.online && !d.revoked)
|
||||
const results = await Promise.allSettled(devices.map(async (d) => ({ device: d, ...(await callDevice(d.id, 'sessions', {})) })))
|
||||
return results.map((r, i) => r.status === 'fulfilled'
|
||||
const live = results.map((r, i) => r.status === 'fulfilled'
|
||||
? { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, items: r.value.items, workspaces: r.value.workspaces }
|
||||
: { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, error: String(r.reason?.message ?? r.reason), items: [] })
|
||||
: { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, error: String(r.reason?.message ?? r.reason), items: syncEngine.offlineSessions(devices[i].id), replica: true })
|
||||
// Offline devices (incl. peers only known from replicas): recent sessions from local replicas,
|
||||
// so they can still be continued here.
|
||||
const known = devicesView()
|
||||
const offline = new Map()
|
||||
for (const d of known) if (!d.self && !d.revoked && !d.online) offline.set(d.id, d.name)
|
||||
for (const id of syncEngine.replicaDevices()) if (!known.some((d) => d.id === id && d.online) && id !== self.id && !offline.has(id)) offline.set(id, syncEngine.deviceName(id))
|
||||
return [...live, ...[...offline].map(([id, name]) => ({ device: id, name, self: false, offline: true, replica: true, items: syncEngine.offlineSessions(id), workspaces: [] }))]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ session activity watch
|
||||
@@ -512,15 +724,9 @@ export function apply(ctx, config = {}) {
|
||||
} else if (name === 'peers' && role === 'slave' && masterLink) {
|
||||
masterLink.peers = data
|
||||
notify({ type: 'devices' })
|
||||
syncEngine.deviceOnline() // a device came back: deliver queued move notices to it
|
||||
} else if (name === 'sync-available' && role === 'slave') {
|
||||
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
||||
} else if (name === 'session-moved' || name === 'session-conflict') {
|
||||
notify(data)
|
||||
if (role === 'master') {
|
||||
for (const [id, l] of links) {
|
||||
if (id !== fromDevice) l.mux.event(name, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,17 +767,7 @@ export function apply(ctx, config = {}) {
|
||||
})
|
||||
log.info?.(`dsh-remote: ${id} connected`)
|
||||
pushPeers()
|
||||
// Notify offline remote ASAP of any session moves that happened while it was disconnected
|
||||
try {
|
||||
const pending = store.popPendingMovesForDevice(id)
|
||||
if (pending && pending.length > 0) {
|
||||
for (const move of pending) {
|
||||
entry.mux.call('recordSessionMoved', move, 15000).catch((err) => {
|
||||
log.warn?.(`dsh-remote: failed to notify ${id} of session move: ${err.message}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
syncEngine.deviceOnline() // deliver move notices queued while it was offline, right away
|
||||
},
|
||||
}), 'dsh-remote: link endpoint')
|
||||
}
|
||||
@@ -628,17 +824,7 @@ export function apply(ctx, config = {}) {
|
||||
lastLinkError = null
|
||||
authRequired = null
|
||||
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
|
||||
// Notify master of any session moves queued while offline
|
||||
try {
|
||||
const pending = store.popPendingMovesForDevice('master')
|
||||
if (pending && pending.length > 0) {
|
||||
for (const move of pending) {
|
||||
mux.call('recordSessionMoved', move, 15000).catch((err) => {
|
||||
log.warn?.(`dsh-remote: failed to notify master of session move: ${err.message}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
syncEngine.deviceOnline() // we were offline: deliver queued move notices right away
|
||||
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
||||
} catch (e) {
|
||||
if (masterLink && !masterLink.info) masterLink = null // never clear a link that finished its handshake
|
||||
@@ -1150,74 +1336,23 @@ document.getElementById('go').onclick=()=>{
|
||||
return { device, sessionId: r.sessionId, url: device === self.id ? `./#/session/${r.sessionId}` : `./r/${device}/#/session/${r.sessionId}` }
|
||||
})
|
||||
|
||||
// Continue a conversation on another device (default: this one). Body:
|
||||
// { sourceDevice, sessionId, targetDevice?, workspaceId?, title? }
|
||||
route('/dsh-remote/continue-session', ['POST'], async (req) => {
|
||||
const body = await readJson(req)
|
||||
const sourceDevice = String(body.sourceDevice || body.device || self.id)
|
||||
const targetDevice = String(body.targetDevice || self.id)
|
||||
const sessionId = String(body.sessionId || '')
|
||||
if (!sessionId) throw httpErr(400, 'sessionId required')
|
||||
|
||||
let exportData
|
||||
if (sourceDevice === self.id) {
|
||||
exportData = await handlers.exportSession({ sessionId })
|
||||
} else {
|
||||
try {
|
||||
exportData = await callDevice(sourceDevice, 'exportSession', { sessionId }, 30000)
|
||||
} catch (err) {
|
||||
const lin = store.sessionLineage()[sessionId]
|
||||
exportData = {
|
||||
sessionId,
|
||||
meta: { title: lin?.title || sessionId, cwd: lin?.cwd },
|
||||
events: [],
|
||||
lastSeq: lin?.baseSeq ?? 0,
|
||||
versionToken: lin?.versionToken || sha256(sessionId),
|
||||
title: lin?.title || sessionId,
|
||||
cwd: lin?.cwd,
|
||||
offlineSource: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let importRes
|
||||
if (targetDevice === self.id) {
|
||||
importRes = await handlers.importSession(exportData)
|
||||
} else {
|
||||
importRes = await callDevice(targetDevice, 'importSession', exportData, 30000)
|
||||
}
|
||||
|
||||
const newSessionId = importRes.sessionId
|
||||
const notice = {
|
||||
sessionId,
|
||||
fromDevice: sourceDevice,
|
||||
toDevice: targetDevice,
|
||||
targetSessionId: newSessionId,
|
||||
atSeq: exportData.lastSeq ?? 0,
|
||||
versionToken: exportData.versionToken,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
if (sourceDevice === self.id) {
|
||||
await handlers.recordSessionMoved(notice)
|
||||
} else {
|
||||
try {
|
||||
await callDevice(sourceDevice, 'recordSessionMoved', notice, 10000)
|
||||
} catch {
|
||||
store.queueMoveNotice(notice)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceDevice,
|
||||
targetDevice,
|
||||
originalSessionId: sessionId,
|
||||
sessionId: newSessionId,
|
||||
url: targetDevice === self.id ? `./#/session/${newSessionId}` : `./r/${targetDevice}/#/session/${newSessionId}`,
|
||||
conflict: importRes.conflict || false,
|
||||
versionToken: exportData.versionToken,
|
||||
}
|
||||
const sourceDevice = String(body.sourceDevice || self.id)
|
||||
const targetDevice = body.targetDevice ? resolveDevice(body.targetDevice) : self.id
|
||||
return syncEngine.continueSession({ sourceDevice, sessionId, targetDevice, workspaceId: body.workspaceId || undefined, title: body.title })
|
||||
})
|
||||
// Lineage ledger + queued notices of this device (for the UI and tooling).
|
||||
route('/dsh-remote/session-sync', ['GET'], async () => syncEngine.state())
|
||||
// Acknowledge a conflict (both branches stay; the warning goes away).
|
||||
route('/dsh-remote/session-sync/resolve', ['POST'], async (req) => {
|
||||
const body = await readJson(req)
|
||||
return syncEngine.resolve(String(body.sessionId || ''))
|
||||
})
|
||||
|
||||
route('/dsh-remote/session-lineage', ['GET'], async () => store.sessionLineage())
|
||||
|
||||
// Server-sent events for the client: devices changes, activity, sync results.
|
||||
route('/dsh-remote/events', ['GET'], async () => ({}))
|
||||
|
||||
Reference in new issue
Block a user