diff --git a/README.md b/README.md index 703f61c..37192d5 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,9 @@ Best-effort session artifact sync via Git paths: - `~/.local/share/opencode/storage/session_diff/` This mode can conflict with concurrent writers. +`/sync-link` restores the repo's session snapshot on a new machine. On an already linked machine, +`/sync-pull` reapplies that snapshot even when Git has no new commits, replacing local session +artifacts. Restart opencode after either command to load the restored sessions. Large `opencode.db` files and legacy files under `storage/message/` are represented as a small, versioned pointer plus 40 MiB parts once they exceed 50 MiB. Parts live in the plugin-owned diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 897329a..0e0e9f7 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -69,6 +69,23 @@ export async function syncRepoToLocal( } } +export async function syncSessionArtifactsRepoToLocal( + plan: SyncPlan, + options: { chunkOptions?: ChunkOptions } = {} +): Promise { + let restored = false; + for (const item of plan.items) { + if (!item.preserveWhenMissing || !(await pathExists(item.repoPath))) continue; + if (item.chunkLargeFiles) { + await copyChunkableItemFromRepo(item, plan.repoRoot, options.chunkOptions); + } else { + await copyItem(item.repoPath, item.localPath, item.type); + } + restored = true; + } + return restored; +} + export async function syncLocalToRepo( plan: SyncPlan, overrides: Record | null, diff --git a/src/sync/service.test.ts b/src/sync/service.test.ts index 0d3cf6b..5d12cb9 100644 --- a/src/sync/service.test.ts +++ b/src/sync/service.test.ts @@ -6,7 +6,7 @@ import { promisify } from 'node:util'; import type { PluginInput } from '@opencode-ai/plugin'; import { describe, expect, it } from 'vitest'; -import { loadState, loadSyncConfig, writeSyncConfig } from './config.js'; +import { loadState, loadSyncConfig, writeState, writeSyncConfig } from './config.js'; import { resolveSyncLocations } from './paths.js'; import { createSyncService } from './service.js'; @@ -110,6 +110,88 @@ async function withIsolatedEnvironment(run: (root: string) => Promise): Pr } describe('explicit Git remote service flow', () => { + it('restores sessions on link and an up-to-date pull', async () => { + await withIsolatedEnvironment(async (root) => { + const testShell = createTestShell(); + const writeSqlite = async (dbPath: string, sql: string): Promise => { + const script = + 'import sqlite3, sys; db = sqlite3.connect(sys.argv[1]); db.executescript(sys.argv[2]); db.commit(); db.close()'; + await testShell`python3 -c ${script} ${dbPath} ${sql}`.quiet(); + }; + const readSessionTitle = async (dbPath: string): Promise => { + const script = + 'import sqlite3, sys; db = sqlite3.connect(sys.argv[1]); row = db.execute("SELECT title FROM session WHERE id = ?", ("ses_issue_51",)).fetchone(); print(row[0] if row else ""); db.close()'; + return (await testShell`python3 -c ${script} ${dbPath}`.text()).trim(); + }; + const remotePath = path.join(root, 'sync-remote.git'); + await testShell`git init --bare ${remotePath}`.quiet(); + + const machineAHome = path.join(root, 'machine-a'); + useIsolatedHome(machineAHome); + const machineALocations = resolveSyncLocations(); + await fs.mkdir(machineALocations.configRoot, { recursive: true }); + await fs.writeFile(path.join(machineALocations.configRoot, 'opencode.json'), '{}\n'); + const machineADbPath = path.join(machineALocations.xdg.dataDir, 'opencode', 'opencode.db'); + await fs.mkdir(path.dirname(machineADbPath), { recursive: true }); + await writeSqlite( + machineADbPath, + "CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT); INSERT INTO session VALUES ('ses_issue_51', 'Machine A');" + ); + + const machineAService = createSyncService({ client: createClient(), $: testShell }); + await machineAService.init({ + repo: remotePath, + branch: 'main', + includeSecrets: true, + includeSessions: true, + acknowledgePrivateRemote: true, + }); + + useIsolatedHome(path.join(root, 'machine-unacknowledged')); + const unacknowledgedLocations = resolveSyncLocations(); + const unacknowledgedService = createSyncService({ client: createClient(), $: testShell }); + await expect( + unacknowledgedService.link({ repo: remotePath, branch: 'main' }) + ).rejects.toThrow('privacy cannot be verified'); + await expect( + fs.stat(path.join(unacknowledgedLocations.xdg.dataDir, 'opencode', 'opencode.db')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + + const machineBHome = path.join(root, 'machine-b'); + useIsolatedHome(machineBHome); + const machineBLocations = resolveSyncLocations(); + const machineBDbPath = path.join(machineBLocations.xdg.dataDir, 'opencode', 'opencode.db'); + const machineBService = createSyncService({ client: createClient(), $: testShell }); + await machineBService.link({ + repo: remotePath, + branch: 'main', + acknowledgePrivateRemote: true, + }); + + await expect(readSessionTitle(machineBDbPath)).resolves.toBe('Machine A'); + const stateAfterLink = await loadState(machineBLocations); + expect(stateAfterLink.lastRemoteUpdate).toBeDefined(); + await writeState(machineBLocations, { ...stateAfterLink, lastPull: undefined }); + await expect(machineBService.status()).resolves.toContain('Last pull: never'); + + await writeSqlite(machineBDbPath, 'DELETE FROM session;'); + + await fs.writeFile( + path.join(machineBLocations.configRoot, 'opencode.json'), + '{"theme":"local"}\n' + ); + + await expect(machineBService.pull()).resolves.toContain('Restart opencode to load them'); + await expect(readSessionTitle(machineBDbPath)).resolves.toBe('Machine A'); + await expect(machineBService.status()).resolves.toMatch(/Last pull: \d{4}-\d\d-\d\dT/u); + const stateAfterPull = await loadState(machineBLocations); + expect(stateAfterPull.lastRemoteUpdate).toBe(stateAfterLink.lastRemoteUpdate); + await expect( + fs.readFile(path.join(machineBLocations.configRoot, 'opencode.json'), 'utf8') + ).resolves.toContain('local'); + }); + }, 30_000); + it('initializes, links, pushes, and pulls through a local bare remote', async () => { await withIsolatedEnvironment(async (root) => { const testShell = createTestShell(); diff --git a/src/sync/service.ts b/src/sync/service.ts index fa418bf..c59123e 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { PluginInput } from '@opencode-ai/plugin'; -import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; +import { syncLocalToRepo, syncRepoToLocal, syncSessionArtifactsRepoToLocal } from './apply.js'; import { generateCommitMessage } from './commit.js'; import type { NormalizedSyncConfig } from './config.js'; import { @@ -936,6 +936,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { await acknowledgePrivateRemote(locations, syncedConfig); } await ensureSensitiveSyncPolicy(ctx, locations, syncedConfig); + const sessionPlan = buildSyncPlan(syncedConfig, locations, repoRoot); + await syncSessionArtifactsRepoToLocal(sessionPlan); } if (syncedConfig && isTursoSessionBackend(syncedConfig)) { const setup = await runTursoSetup(syncedConfig, { allowLogin: true }); @@ -991,8 +993,22 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const update = await fetchAndFastForward(ctx.$, repoRoot, branch); if (!update.updated) { + const plan = buildSyncPlan(config, locations, repoRoot); + const restoredSessions = await syncSessionArtifactsRepoToLocal(plan); const tursoSummary = await runForegroundTursoCycle(config, 'pull-up-to-date'); ensureTursoSyncLoop(config); + if (restoredSessions) { + await updateState(locations, { lastPull: new Date().toISOString() }); + await showToast( + ctx.client, + 'Sessions restored. Restart opencode to load them.', + 'info' + ); + return [ + 'Remote sessions restored. Restart opencode to load them.', + ...(tursoSummary ? [tursoSummary] : []), + ].join('\n'); + } if (tursoSummary) { return ['Already up to date.', tursoSummary].join('\n'); }