feat(sync): seamless conversation sync, git-like version control, and offline remote notification
This commit is contained in:
1 parent
8e150dcacd
commit
0f866b1bbd
6 files changed
+471
-17
No files matched your search
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user