Skip to content

Commit 6ff20d8

Browse files
committed
feat(sync): enhance sync configuration with activity-aware batching and migration support
1 parent c0cff7f commit 6ff20d8

8 files changed

Lines changed: 222 additions & 33 deletions

File tree

README.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Sync your OpenCode configuration, sessions, and data across multiple machines us
88
- **Multi-Machine Support** - Safely sync across laptops, VMs, and servers simultaneously
99
- **Conflict Resolution** - Vector clocks detect conflicts, three-way merge resolves them
1010
- **Encrypted Credentials** - AES-256-GCM encryption for sensitive data
11-
- **Auto-Sync** - Syncs on startup, on file changes (2s debounce), and every minute
11+
- **Auto-Sync** - Syncs on startup, on file changes (5s debounce, 30s max), and every 5 minutes
1212
- **Offline-Friendly** - Works offline, syncs when reconnected
1313
- **Atomic Updates** - Uses Git's compare-and-swap for safe concurrent access
1414

@@ -52,12 +52,10 @@ npm install && npm run build
5252
| State | Model selections, prompt history, stashed prompts | ✅ Enabled |
5353
| Credentials | OAuth tokens, MCP auth (encrypted) | ✅ Enabled |
5454
| Sessions | Session metadata and history | ✅ Enabled |
55-
| Messages | Conversation messages and parts | ❌ Disabled* |
55+
| Messages | Conversation messages and parts | ✅ Enabled |
5656
| Projects | Project configurations | ✅ Enabled |
5757
| Todos | Task lists and session diffs | ✅ Enabled |
5858

59-
*Messages sync is disabled by default because it can be very large (8MB+). Enable it in config if needed.
60-
6159
## How It Works
6260

6361
### Vector Clocks for Conflict Detection
@@ -154,33 +152,38 @@ On subsequent runs:
154152
"repoName": ".opencode-sync",
155153
"autoSyncOnStartup": true,
156154
"continuousSync": true,
157-
"syncIntervalMinutes": 1,
155+
"syncIntervalMinutes": 5,
156+
"fileWatcherDebounceMs": 5000,
157+
"maxDebounceMs": 30000,
158158
"sync": {
159159
"config": true,
160160
"state": true,
161161
"credentials": true,
162162
"sessions": true,
163-
"messages": false,
163+
"messages": true,
164164
"projects": true,
165165
"todos": true
166166
},
167167
"conflictStrategy": "auto-merge"
168168
}
169169
```
170170

171-
**Note:** `messages` is `false` by default because conversation history can be very large (8MB+). Set to `true` if you want to sync messages across machines.
171+
**Note:** All categories are enabled by default. Disable `messages` if you have very large conversation history (8MB+) and want to reduce sync size.
172172

173173
## Sync Timing
174174

175-
The plugin syncs at these times:
175+
The plugin uses **activity-aware batching** to prevent excessive syncs during heavy IO:
176+
177+
| Trigger | Default | Description |
178+
|---------|---------|-------------|
179+
| **Startup** | Enabled | Immediately when OpenCode starts |
180+
| **File Changes** | 5s debounce | Wait for inactivity before syncing |
181+
| **Max Delay** | 30s cap | Force sync even during heavy activity |
182+
| **Interval** | 5 minutes | Periodic sync regardless of changes |
176183

177-
| Trigger | When |
178-
|---------|------|
179-
| **Startup** | Immediately when OpenCode starts |
180-
| **File Changes** | 2 seconds after local OpenCode files change |
181-
| **Interval** | Every 1 minute (configurable) |
184+
During heavy activity, syncs are batched and fire at most every 30 seconds.
182185

183-
This is **not realtime** - there's typically a 1-60 second delay depending on when changes occur.
186+
See [docs/SYNC.md](docs/SYNC.md) for detailed architecture documentation.
184187

185188
## Security
186189

docs/SYNC.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Sync Architecture
2+
3+
## Sync Triggers
4+
5+
| Trigger | Default | Config Key |
6+
|---------|---------|------------|
7+
| Startup | Enabled | `autoSyncOnStartup` |
8+
| File Changes | 5s debounce | `fileWatcherDebounceMs` |
9+
| Max Delay | 30s cap | `maxDebounceMs` |
10+
| Interval | 5 min | `syncIntervalMinutes` |
11+
12+
## Activity-Aware Batching
13+
14+
Two timers prevent excessive syncs during heavy IO:
15+
16+
- **Debounce** (5s): Resets on each file change
17+
- **Max Delay** (30s): Never resets, forces sync
18+
19+
Sync fires on whichever timer completes first.
20+
21+
```
22+
Heavy IO: [changes...] ──────────────────────> 30s max delay fires
23+
Light IO: [change] ─────> 5s inactivity ─────> debounce fires
24+
```
25+
26+
## Configuration
27+
28+
```json
29+
{
30+
"syncIntervalMinutes": 5,
31+
"fileWatcherDebounceMs": 5000,
32+
"maxDebounceMs": 30000
33+
}
34+
```
35+
36+
## Data Categories
37+
38+
**Blob** (single compressed file): config, state, credentials, projects, todos
39+
40+
**Item** (per-file sync): sessions, messages
41+
42+
## Remote Storage Structure
43+
44+
```
45+
.opencode-sync/
46+
├── manifest.json
47+
├── {category}.json.gz.b64 # Blob categories
48+
├── {category}-shard.json # Item category index
49+
├── sessions/{projectHash}/{id}.json.gz
50+
└── messages/
51+
├── {sessionId}/{msgId}.json.gz
52+
└── parts/{msgId}/{partId}.json.gz
53+
```
54+
55+
## Key Files
56+
57+
| File | Purpose |
58+
|------|---------|
59+
| `src/sync/watcher/file-watcher.ts` | Debounce + max delay |
60+
| `src/sync/engine/sync-engine.ts` | Sync orchestration |
61+
| `src/sync/operations/push.ts` | Local → Remote |
62+
| `src/sync/operations/pull.ts` | Remote → Local |
63+
| `src/sync/item-packer.ts` | Per-item compression |
64+
| `src/types/config.ts` | Config defaults |

src/data/state.ts

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,40 +16,43 @@ const ENV_TOKEN_KEY = 'GITHUB_TOKEN';
1616

1717
/**
1818
* Load plugin configuration from disk with environment variable fallback.
19+
* Automatically migrates config: adds missing keys, removes obsolete ones.
1920
*
2021
* Token resolution order:
2122
* 1. Config file token (if exists and non-empty)
2223
* 2. GITHUB_TOKEN environment variable
2324
* 3. null (no token available)
2425
*/
2526
export async function loadConfig(pathConfig: PathConfig): Promise<SyncConfig | null> {
26-
let config: SyncConfig | null = null;
27+
let rawConfig: Record<string, unknown> | null = null;
2728

2829
try {
2930
const content = await readFile(pathConfig.pluginConfigPath, 'utf-8');
30-
config = JSON.parse(content) as SyncConfig;
31+
rawConfig = JSON.parse(content) as Record<string, unknown>;
3132
} catch {
3233
// Config file doesn't exist or is invalid - will try env var
3334
}
3435

3536
const envToken = process.env[ENV_TOKEN_KEY];
3637

37-
if (config) {
38-
// Config exists - merge with defaults and use env var as fallback if no token
39-
const mergedConfig = {
40-
...DEFAULT_CONFIG,
41-
...config,
42-
sync: { ...DEFAULT_CONFIG.sync, ...config.sync },
43-
};
38+
if (rawConfig) {
39+
// Migrate config: use defaults, keep user values, remove obsolete keys
40+
const migratedConfig = migrateConfig(rawConfig);
4441

45-
if (!mergedConfig.token && envToken) {
46-
mergedConfig.token = envToken;
42+
if (!migratedConfig.token && envToken) {
43+
migratedConfig.token = envToken;
4744
}
48-
// Generate machineId if missing
49-
if (!mergedConfig.machineId) {
50-
mergedConfig.machineId = generateMachineId();
45+
if (!migratedConfig.machineId) {
46+
migratedConfig.machineId = generateMachineId();
47+
}
48+
49+
// Save migrated config if it changed
50+
const configChanged = !configsEqual(rawConfig, migratedConfig as unknown as RawConfig);
51+
if (configChanged) {
52+
await saveConfig(pathConfig, migratedConfig);
5153
}
52-
return mergedConfig;
54+
55+
return migratedConfig;
5356
}
5457

5558
// No config file - create minimal config from env var if available
@@ -64,6 +67,86 @@ export async function loadConfig(pathConfig: PathConfig): Promise<SyncConfig | n
6467
return null;
6568
}
6669

70+
/** Raw config from disk (untyped) */
71+
type RawConfig = Record<string, unknown>;
72+
73+
/** Keys that are user-specific and should always be preserved */
74+
const USER_KEYS = [
75+
'token',
76+
'machineId',
77+
'repoOwner',
78+
'repoName',
79+
'branch',
80+
'keySalt',
81+
'passphraseHash',
82+
'oldEncryptionKey',
83+
] as const;
84+
85+
/**
86+
* Migrate config to latest schema.
87+
* - Adds missing keys from DEFAULT_CONFIG
88+
* - Removes keys not in DEFAULT_CONFIG (except user-specific keys)
89+
* - Preserves user values for existing keys
90+
*/
91+
function migrateConfig(raw: RawConfig): SyncConfig {
92+
// Start with defaults
93+
const migrated: RawConfig = { ...DEFAULT_CONFIG };
94+
95+
// Copy user-specific keys
96+
for (const key of USER_KEYS) {
97+
if (key in raw) {
98+
migrated[key] = raw[key];
99+
}
100+
}
101+
102+
// For config keys in DEFAULT_CONFIG, use user value if present
103+
for (const key of Object.keys(DEFAULT_CONFIG)) {
104+
if (!(key in raw)) continue;
105+
if (key === 'sync') {
106+
migrated['sync'] = migrateSyncCategories(raw['sync'] as RawConfig | undefined);
107+
} else {
108+
migrated[key] = raw[key];
109+
}
110+
}
111+
112+
return migrated as unknown as SyncConfig;
113+
}
114+
115+
/**
116+
* Migrate sync categories: merge with defaults, remove obsolete keys.
117+
*/
118+
function migrateSyncCategories(rawSync: RawConfig | undefined): RawConfig {
119+
const merged: RawConfig = { ...DEFAULT_CONFIG.sync, ...rawSync };
120+
const validKeys = new Set(Object.keys(DEFAULT_CONFIG.sync));
121+
const result: RawConfig = {};
122+
for (const key of Object.keys(merged)) {
123+
if (validKeys.has(key)) {
124+
result[key] = merged[key];
125+
}
126+
}
127+
return result;
128+
}
129+
130+
/**
131+
* Check if migration actually changed config values (ignoring key order).
132+
* Only compares keys that exist in DEFAULT_CONFIG + USER_KEYS.
133+
*/
134+
function configsEqual(raw: RawConfig, migrated: RawConfig): boolean {
135+
const allKeys = [...Object.keys(DEFAULT_CONFIG), ...USER_KEYS];
136+
for (const key of allKeys) {
137+
const rawVal = raw[key];
138+
const migVal = migrated[key];
139+
if (rawVal === undefined && migVal === undefined) continue;
140+
if (rawVal === undefined || migVal === undefined) return false;
141+
if (JSON.stringify(rawVal) !== JSON.stringify(migVal)) return false;
142+
}
143+
// Check if raw has extra keys that will be removed
144+
for (const key of Object.keys(raw)) {
145+
if (!(key in migrated)) return false;
146+
}
147+
return true;
148+
}
149+
67150
/**
68151
* Get the source of the GitHub token.
69152
*/

src/plugin/plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ function startFileWatcher(pathConfig: PathConfig): void {
243243
activeWatcher = new FileWatcher({
244244
pathConfig,
245245
debounceMs: state.config.fileWatcherDebounceMs,
246+
maxDebounceMs: state.config.maxDebounceMs,
246247
enabledCategories,
247248
onEvent: async () => {
248249
try {
@@ -321,6 +322,9 @@ export const OpencodeSyncPlugin = async (_ctx: unknown): Promise<Record<string,
321322
log('Plugin ready');
322323
}
323324

325+
// Return cleanup function
326+
// Note: OpenCode doesn't currently expose a shutdown hook for plugins,
327+
// so this cleanup may not be called. See: https://github.com/anomalyco/opencode/issues/XXX
324328
return { cleanup: stopBackgroundSync };
325329
} catch (error) {
326330
const errMsg = error instanceof Error ? error.message : String(error);

src/sync/watcher/file-watcher.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export class FileWatcher {
1414
private readonly watchers = new Map<string, FSWatcher>();
1515
private readonly pendingEvents = new Map<string, WatcherEvent>();
1616
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
17+
private maxDelayTimer: ReturnType<typeof setTimeout> | null = null;
18+
private firstEventTime: number | null = null;
1719
private isRunning = false;
1820

1921
constructor(options: FileWatcherOptions) {
@@ -63,6 +65,12 @@ export class FileWatcher {
6365
this.debounceTimer = null;
6466
}
6567

68+
if (this.maxDelayTimer) {
69+
clearTimeout(this.maxDelayTimer);
70+
this.maxDelayTimer = null;
71+
}
72+
73+
this.firstEventTime = null;
6674
this.pendingEvents.clear();
6775
}
6876

@@ -81,6 +89,17 @@ export class FileWatcher {
8189
const event: WatcherEvent = { type, path, category };
8290
this.pendingEvents.set(path, event);
8391

92+
// Track first event time for max delay cap
93+
if (this.firstEventTime === null) {
94+
this.firstEventTime = Date.now();
95+
96+
// Set up max delay timer - will force flush even if activity continues
97+
this.maxDelayTimer = setTimeout(() => {
98+
void this.flushEvents();
99+
}, this.options.maxDebounceMs);
100+
}
101+
102+
// Reset debounce timer on each event
84103
if (this.debounceTimer) {
85104
clearTimeout(this.debounceTimer);
86105
}
@@ -95,7 +114,17 @@ export class FileWatcher {
95114

96115
const events = Array.from(this.pendingEvents.values());
97116
this.pendingEvents.clear();
98-
this.debounceTimer = null;
117+
118+
// Clear both timers
119+
if (this.debounceTimer) {
120+
clearTimeout(this.debounceTimer);
121+
this.debounceTimer = null;
122+
}
123+
if (this.maxDelayTimer) {
124+
clearTimeout(this.maxDelayTimer);
125+
this.maxDelayTimer = null;
126+
}
127+
this.firstEventTime = null;
99128

100129
try {
101130
await this.options.onEvent(events);

src/sync/watcher/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export function createFileWatcher(
2222
return new FileWatcher({
2323
pathConfig,
2424
onEvent,
25-
debounceMs: options?.debounceMs ?? 2000,
25+
debounceMs: options?.debounceMs ?? 5000,
26+
maxDebounceMs: options?.maxDebounceMs ?? 30000,
2627
enabledCategories:
2728
options?.enabledCategories ??
2829
new Set(['config', 'state', 'credentials', 'sessions', 'messages', 'projects', 'todos']),

src/sync/watcher/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { SyncCategory, WatcherEvent, PathConfig } from '../../types/index.j
99
export interface FileWatcherOptions {
1010
pathConfig: PathConfig;
1111
debounceMs: number;
12+
/** Maximum time to wait before syncing even if activity continues (ms) */
13+
maxDebounceMs: number;
1214
onEvent: (events: WatcherEvent[]) => void | Promise<void>;
1315
enabledCategories: Set<SyncCategory>;
1416
}

src/types/config.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface SyncConfig {
2525
continuousSync: boolean;
2626
syncIntervalMinutes: number;
2727
fileWatcherDebounceMs: number;
28+
/** Maximum time to wait before syncing even if activity continues (ms) */
29+
maxDebounceMs: number;
2830

2931
// What to sync
3032
sync: {
@@ -53,8 +55,9 @@ export interface SyncConfig {
5355
export const DEFAULT_CONFIG: Omit<SyncConfig, 'token' | 'machineId'> = {
5456
autoSyncOnStartup: true,
5557
continuousSync: true,
56-
syncIntervalMinutes: 1,
57-
fileWatcherDebounceMs: 2000,
58+
syncIntervalMinutes: 5,
59+
fileWatcherDebounceMs: 5000,
60+
maxDebounceMs: 30000,
5861
sync: {
5962
config: true,
6063
state: true,

0 commit comments

Comments
 (0)