feat(sync): seamless conversation sync, git-like version control, and offline remote notification

This commit is contained in:
Agent committed 2026-10-21 18:43:29 +00:00
1 parent 8e150dcacd
commit 0f866b1bbd
6 files changed
+471 -17

No files matched your search

+232 -3
View File
@@ -28,7 +28,7 @@
import os from 'node:os'
import { acceptUpgrade, connect, rejectUpgrade } from './lib/ws.mjs'
import { Mux } from './lib/mux.mjs'
import { Store, randomId, setProfileDir, slug } from './lib/store.mjs'
import { Store, randomId, setProfileDir, slug, sha256 } 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'
@@ -52,8 +52,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. */
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession'])
/** 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'])
/** 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,6 +203,136 @@ 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 }
},
}
/**
@@ -270,6 +400,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,
})),
workspaces,
}
@@ -383,6 +514,13 @@ export function apply(ctx, config = {}) {
notify({ type: 'devices' })
} 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)
}
}
}
}
@@ -423,6 +561,17 @@ 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 {}
},
}), 'dsh-remote: link endpoint')
}
@@ -479,6 +628,17 @@ 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 {}
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
@@ -990,6 +1150,75 @@ document.getElementById('go').onclick=()=>{
return { device, sessionId: r.sessionId, url: device === self.id ? `./#/session/${r.sessionId}` : `./r/${device}/#/session/${r.sessionId}` }
})
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,
}
})
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 () => ({}))
ctx.effect(() => {