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:
Agent committed 2026-10-21 19:10:03 +00:00
1 parent 0f866b1bbd
commit 2e699a061f
7 files changed
+904 -461

No files matched your search

+363 -228
View File
@@ -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 () => ({}))