diff --git a/src/agent/file-watcher.ts b/src/agent/file-watcher.ts new file mode 100644 index 00000000..86fdac8c --- /dev/null +++ b/src/agent/file-watcher.ts @@ -0,0 +1,166 @@ +import { watch, type FSWatcher } from 'fs'; +import { access } from 'fs/promises'; +import type { AgentConfig } from '../shared/types'; +import { expandPath } from '../config/loader'; + +const STANDARD_CREDENTIAL_FILES = [ + '~/.gitconfig', + '~/.claude/.credentials.json', + '~/.codex/auth.json', + '~/.codex/config.toml', +]; + +interface FileWatcherOptions { + config: AgentConfig; + syncCallback: () => Promise; + debounceMs?: number; +} + +export class FileWatcher { + private watchers: Map = new Map(); + private config: AgentConfig; + private syncCallback: () => Promise; + private debounceMs: number; + private debounceTimer: ReturnType | null = null; + private pendingSync = false; + + constructor(options: FileWatcherOptions) { + this.config = options.config; + this.syncCallback = options.syncCallback; + this.debounceMs = options.debounceMs ?? 500; + this.setupWatchers(); + } + + updateConfig(config: AgentConfig): void { + this.config = config; + this.rebuildWatchers(); + } + + stop(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + for (const [filePath, watcher] of this.watchers) { + watcher.close(); + console.log(`[file-watcher] Stopped watching: ${filePath}`); + } + this.watchers.clear(); + } + + private collectWatchPaths(): string[] { + const paths = new Set(); + + for (const sourcePath of Object.values(this.config.credentials.files)) { + paths.add(expandPath(sourcePath)); + } + + for (const stdPath of STANDARD_CREDENTIAL_FILES) { + paths.add(expandPath(stdPath)); + } + + if (this.config.ssh?.global?.copy) { + for (const keyPath of this.config.ssh.global.copy) { + paths.add(expandPath(keyPath)); + } + } + + if (this.config.ssh?.workspaces) { + for (const wsConfig of Object.values(this.config.ssh.workspaces)) { + if (wsConfig.copy) { + for (const keyPath of wsConfig.copy) { + paths.add(expandPath(keyPath)); + } + } + } + } + + return Array.from(paths); + } + + private async setupWatchers(): Promise { + const paths = this.collectWatchPaths(); + + for (const filePath of paths) { + await this.watchFile(filePath); + } + } + + private async watchFile(filePath: string): Promise { + if (this.watchers.has(filePath)) { + return; + } + + try { + await access(filePath); + } catch { + return; + } + + try { + const watcher = watch(filePath, (eventType) => { + if (eventType === 'change' || eventType === 'rename') { + this.handleFileChange(filePath); + } + }); + + watcher.on('error', (err) => { + console.error(`[file-watcher] Error watching ${filePath}:`, err); + this.watchers.delete(filePath); + }); + + this.watchers.set(filePath, watcher); + console.log(`[file-watcher] Watching: ${filePath}`); + } catch (err) { + console.error(`[file-watcher] Failed to watch ${filePath}:`, err); + } + } + + private handleFileChange(filePath: string): void { + console.log(`[file-watcher] Change detected: ${filePath}`); + this.scheduleSync(); + } + + private scheduleSync(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.pendingSync = true; + this.debounceTimer = setTimeout(async () => { + this.debounceTimer = null; + if (this.pendingSync) { + this.pendingSync = false; + try { + console.log('[file-watcher] Triggering sync...'); + await this.syncCallback(); + console.log('[file-watcher] Sync completed'); + } catch (err) { + console.error('[file-watcher] Sync failed:', err); + } + } + }, this.debounceMs); + } + + private rebuildWatchers(): void { + const newPaths = new Set(this.collectWatchPaths()); + const currentPaths = new Set(this.watchers.keys()); + + for (const filePath of currentPaths) { + if (!newPaths.has(filePath)) { + const watcher = this.watchers.get(filePath); + if (watcher) { + watcher.close(); + this.watchers.delete(filePath); + console.log(`[file-watcher] Stopped watching: ${filePath}`); + } + } + } + + for (const filePath of newPaths) { + if (!currentPaths.has(filePath)) { + this.watchFile(filePath); + } + } + } +} diff --git a/src/agent/router.ts b/src/agent/router.ts index 9f8544fc..bc20ab23 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -121,6 +121,7 @@ export interface RouterContext { sessionsCache: SessionsCacheManager; modelCache: ModelCacheManager; tailscale?: TailscaleInfo; + triggerAutoSync: () => void; } function mapErrorToORPC(err: unknown, defaultMessage: string): never { @@ -289,6 +290,7 @@ export function createRouter(ctx: RouterContext) { const newConfig = { ...currentConfig, credentials: input }; ctx.config.set(newConfig); await saveAgentConfig(newConfig, ctx.configDir); + ctx.triggerAutoSync(); return input; }); @@ -304,6 +306,7 @@ export function createRouter(ctx: RouterContext) { const newConfig = { ...currentConfig, scripts: input }; ctx.config.set(newConfig); await saveAgentConfig(newConfig, ctx.configDir); + ctx.triggerAutoSync(); return input; }); @@ -319,6 +322,7 @@ export function createRouter(ctx: RouterContext) { const newConfig = { ...currentConfig, agents: input }; ctx.config.set(newConfig); await saveAgentConfig(newConfig, ctx.configDir); + ctx.triggerAutoSync(); return input; }); @@ -341,6 +345,7 @@ export function createRouter(ctx: RouterContext) { const newConfig = { ...currentConfig, ssh: input }; ctx.config.set(newConfig); await saveAgentConfig(newConfig, ctx.configDir); + ctx.triggerAutoSync(); return input; }); diff --git a/src/agent/run.ts b/src/agent/run.ts index f81d22f8..5dd9f3c1 100644 --- a/src/agent/run.ts +++ b/src/agent/run.ts @@ -14,6 +14,7 @@ import { createRouter } from './router'; import { serveStatic } from './static'; import { SessionsCacheManager } from '../sessions/cache'; import { ModelCacheManager } from '../models/cache'; +import { FileWatcher } from './file-watcher'; import { getTailscaleStatus, getTailscaleIdentity, @@ -42,6 +43,24 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T const sessionsCache = new SessionsCacheManager(configDir); const modelCache = new ModelCacheManager(configDir); + const syncAllRunning = async () => { + const allWorkspaces = await workspaces.list(); + const running = allWorkspaces.filter((ws) => ws.status === 'running'); + for (const ws of running) { + try { + await workspaces.sync(ws.name); + console.log(`[sync] Synced workspace: ${ws.name}`); + } catch (err) { + console.error(`[sync] Failed to sync ${ws.name}:`, err); + } + } + }; + + const fileWatcher = new FileWatcher({ + config: currentConfig, + syncCallback: syncAllRunning, + }); + const isWorkspaceRunning = async (name: string) => { if (name === HOST_WORKSPACE_NAME) { return currentConfig.allowHostAccess === true; @@ -67,6 +86,12 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T getConfig: () => currentConfig, }); + const triggerAutoSync = () => { + syncAllRunning().catch((err) => { + console.error('[sync] Auto-sync failed:', err); + }); + }; + const router = createRouter({ workspaces, config: { @@ -74,6 +99,7 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T set: (newConfig: AgentConfig) => { currentConfig = newConfig; workspaces.updateConfig(newConfig); + fileWatcher.updateConfig(newConfig); }, }, configDir, @@ -83,6 +109,7 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T sessionsCache, modelCache, tailscale, + triggerAutoSync, }); const rpcHandler = new RPCHandler(router); @@ -152,7 +179,7 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T } }); - return { server, terminalServer, chatServer, opencodeServer }; + return { server, terminalServer, chatServer, opencodeServer, fileWatcher }; } export interface StartAgentOptions { @@ -232,7 +259,7 @@ export async function startAgent(options: StartAgentOptions = {}): Promise } : undefined; - const { server, terminalServer, chatServer, opencodeServer } = createAgentServer( + const { server, terminalServer, chatServer, opencodeServer, fileWatcher } = createAgentServer( configDir, config, tailscaleInfo @@ -272,6 +299,7 @@ export async function startAgent(options: StartAgentOptions = {}): Promise const shutdown = async () => { console.log('[agent] Shutting down...'); + fileWatcher.stop(); if (tailscaleServeActive) { console.log('[agent] Stopping Tailscale Serve...'); await stopTailscaleServe(); diff --git a/src/shared/types.ts b/src/shared/types.ts index 5afa79e9..7bb4a97e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,12 @@ export interface WorkspaceCredentials { env: Record; + /** + * File or directory mappings from host to workspace. + * Key is destination path (in workspace), value is source path (on host). + * Paths starting with ~/ are expanded to the home directory. + * Directories are copied recursively via TAR. + * Example: { "~/.ssh/id_rsa": "~/.ssh/id_rsa", "~/.config/myapp": "~/.config/myapp" } + */ files: Record; } diff --git a/test/helpers/agent.ts b/test/helpers/agent.ts index fd6afe4d..d97bb093 100644 --- a/test/helpers/agent.ts +++ b/test/helpers/agent.ts @@ -14,6 +14,7 @@ import type { WorkspaceInfo, CreateWorkspaceRequest, ApiError, + WorkspaceCredentials, } from '../../src/shared/types'; interface ExecResult { @@ -36,6 +37,8 @@ interface ApiClient { deleteWorkspace(name: string): Promise<{ status: number }>; startWorkspace(name: string): Promise>; stopWorkspace(name: string): Promise>; + updateCredentials(credentials: WorkspaceCredentials): Promise; + syncWorkspace(name: string): Promise; } export interface TestAgent { @@ -183,6 +186,14 @@ export function createApiClient(baseUrl: string): ApiClient { }; } }, + + async updateCredentials(credentials: WorkspaceCredentials): Promise { + return client.config.credentials.update(credentials); + }, + + async syncWorkspace(name: string): Promise { + await client.workspaces.sync({ name }); + }, }; } diff --git a/test/integration/auto-sync.test.ts b/test/integration/auto-sync.test.ts new file mode 100644 index 00000000..18890c73 --- /dev/null +++ b/test/integration/auto-sync.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { startTestAgent, generateTestWorkspaceName, type TestAgent } from '../helpers/agent'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('Auto-Sync', () => { + let agent: TestAgent; + let workspaceName: string; + let tempDir: string; + + beforeAll(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-auto-sync-test-')); + }, 60000); + + afterAll(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + workspaceName = generateTestWorkspaceName(); + }); + + afterEach(async () => { + try { + await agent?.api.deleteWorkspace(workspaceName); + } catch { + // Ignore + } + if (agent) { + await agent.cleanup(); + } + }); + + describe('API Config Changes', () => { + it('syncs credentials to running workspace when config updated via API', async () => { + const testFile = path.join(tempDir, 'sync-test.txt'); + await fs.writeFile(testFile, 'initial-content'); + + agent = await startTestAgent({ + config: { + credentials: { + env: {}, + files: { '~/.sync-test': testFile }, + }, + }, + }); + + await agent.api.createWorkspace({ name: workspaceName }); + + let result = await agent.exec(workspaceName, 'cat /home/workspace/.sync-test'); + expect(result.stdout).toBe('initial-content'); + + const newFile = path.join(tempDir, 'new-sync-test.txt'); + await fs.writeFile(newFile, 'new-content'); + + await fetch(`${agent.baseUrl}/rpc/config/credentials/update`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + json: { + env: {}, + files: { + '~/.sync-test': testFile, + '~/.new-sync-test': newFile, + }, + }, + }), + }); + + await sleep(1000); + + result = await agent.exec(workspaceName, 'cat /home/workspace/.new-sync-test'); + expect(result.stdout).toBe('new-content'); + }, 180000); + }); + + describe('File Watching', () => { + it('syncs to workspace when watched credential file changes', async () => { + const watchedFile = path.join(tempDir, 'watched.txt'); + await fs.writeFile(watchedFile, 'original'); + + agent = await startTestAgent({ + config: { + credentials: { + env: {}, + files: { '~/.watched': watchedFile }, + }, + }, + }); + + await agent.api.createWorkspace({ name: workspaceName }); + + let result = await agent.exec(workspaceName, 'cat /home/workspace/.watched'); + expect(result.stdout).toBe('original'); + + await fs.writeFile(watchedFile, 'modified-content'); + + await sleep(1500); + + result = await agent.exec(workspaceName, 'cat /home/workspace/.watched'); + expect(result.stdout).toBe('modified-content'); + }, 180000); + + it('debounces rapid file changes', async () => { + const watchedFile = path.join(tempDir, 'debounce-test.txt'); + await fs.writeFile(watchedFile, 'v1'); + + agent = await startTestAgent({ + config: { + credentials: { + env: {}, + files: { '~/.debounce-test': watchedFile }, + }, + }, + }); + + await agent.api.createWorkspace({ name: workspaceName }); + + await fs.writeFile(watchedFile, 'v2'); + await sleep(100); + await fs.writeFile(watchedFile, 'v3'); + await sleep(100); + await fs.writeFile(watchedFile, 'v4-final'); + + await sleep(1500); + + const result = await agent.exec(workspaceName, 'cat /home/workspace/.debounce-test'); + expect(result.stdout).toBe('v4-final'); + }, 180000); + }); + + describe('Directory Sync', () => { + it('syncs entire directory to workspace', async () => { + const syncDir = path.join(tempDir, 'sync-dir'); + await fs.mkdir(syncDir, { recursive: true }); + await fs.writeFile(path.join(syncDir, 'file1.txt'), 'content1'); + await fs.writeFile(path.join(syncDir, 'file2.txt'), 'content2'); + await fs.mkdir(path.join(syncDir, 'subdir')); + await fs.writeFile(path.join(syncDir, 'subdir', 'nested.txt'), 'nested-content'); + + agent = await startTestAgent({ + config: { + credentials: { + env: {}, + files: { '~/.sync-dir': syncDir }, + }, + }, + }); + + await agent.api.createWorkspace({ name: workspaceName }); + + let result = await agent.exec(workspaceName, 'cat /home/workspace/.sync-dir/file1.txt'); + expect(result.stdout).toBe('content1'); + + result = await agent.exec(workspaceName, 'cat /home/workspace/.sync-dir/file2.txt'); + expect(result.stdout).toBe('content2'); + + result = await agent.exec(workspaceName, 'cat /home/workspace/.sync-dir/subdir/nested.txt'); + expect(result.stdout).toBe('nested-content'); + }, 180000); + }); +}); diff --git a/web/src/components/SyncToast.tsx b/web/src/components/SyncToast.tsx index 8364f0ab..4a9f6805 100644 --- a/web/src/components/SyncToast.tsx +++ b/web/src/components/SyncToast.tsx @@ -1,8 +1,5 @@ -import { useState, useEffect } from 'react' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { RefreshCw, X, Check } from 'lucide-react' -import { api } from '@/lib/api' -import { cn } from '@/lib/utils' +import { useEffect } from 'react' +import { Check } from 'lucide-react' interface SyncToastProps { show: boolean @@ -10,67 +7,23 @@ interface SyncToastProps { } export function SyncToast({ show, onDismiss }: SyncToastProps) { - const queryClient = useQueryClient() - const [synced, setSynced] = useState(false) - - const mutation = useMutation({ - mutationFn: api.syncAllWorkspaces, - onSuccess: () => { - setSynced(true) - queryClient.invalidateQueries({ queryKey: ['workspaces'] }) - setTimeout(() => { - onDismiss() - setSynced(false) - }, 1500) - }, - }) - useEffect(() => { - if (!show) { - setSynced(false) + if (show) { + const timer = setTimeout(onDismiss, 2500) + return () => clearTimeout(timer) } - }, [show]) + }, [show, onDismiss]) if (!show) return null return ( -
-
- {synced ? ( - <> - - Synced to all workspaces - - ) : ( - <> -
-

Sync to workspaces?

-

Push changes to running workspaces

-
-
- - -
- - )} +
+
+ + Synced to workspaces
) diff --git a/web/src/contexts/SyncContext.tsx b/web/src/contexts/SyncContext.tsx index ddec3ed0..57da0593 100644 --- a/web/src/contexts/SyncContext.tsx +++ b/web/src/contexts/SyncContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from import { SyncToast } from '@/components/SyncToast' interface SyncContextValue { - showSyncPrompt: () => void + showSyncNotification: () => void } const SyncContext = createContext(null) @@ -10,7 +10,7 @@ const SyncContext = createContext(null) export function SyncProvider({ children }: { children: ReactNode }) { const [showToast, setShowToast] = useState(false) - const showSyncPrompt = useCallback(() => { + const showSyncNotification = useCallback(() => { setShowToast(true) }, []) @@ -19,17 +19,17 @@ export function SyncProvider({ children }: { children: ReactNode }) { }, []) return ( - + {children} ) } -export function useSyncPrompt() { +export function useSyncNotification() { const context = useContext(SyncContext) if (!context) { - throw new Error('useSyncPrompt must be used within SyncProvider') + throw new Error('useSyncNotification must be used within SyncProvider') } - return context.showSyncPrompt + return context.showSyncNotification } diff --git a/web/src/pages/settings/Agents.tsx b/web/src/pages/settings/Agents.tsx index 46f68223..64f79d53 100644 --- a/web/src/pages/settings/Agents.tsx +++ b/web/src/pages/settings/Agents.tsx @@ -11,7 +11,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { useSyncPrompt } from '@/contexts/SyncContext' +import { useSyncNotification } from '@/contexts/SyncContext' const FALLBACK_CLAUDE_MODELS: ModelInfo[] = [ { id: 'sonnet', name: 'Sonnet', description: 'Fast and cost-effective' }, @@ -31,7 +31,7 @@ function StatusIndicator({ configured }: { configured: boolean }) { export function AgentsSettings() { const queryClient = useQueryClient() - const showSyncPrompt = useSyncPrompt() + const showSyncNotification = useSyncNotification() const { data: agents, isLoading, error, refetch } = useQuery({ queryKey: ['agents'], @@ -85,7 +85,7 @@ export function AgentsSettings() { setOpencodeHasChanges(false) setGithubHasChanges(false) setClaudeHasChanges(false) - showSyncPrompt() + showSyncNotification() }, }) diff --git a/web/src/pages/settings/Environment.tsx b/web/src/pages/settings/Environment.tsx index a9e1c3f2..4823329f 100644 --- a/web/src/pages/settings/Environment.tsx +++ b/web/src/pages/settings/Environment.tsx @@ -4,11 +4,11 @@ import { Plus, Trash2, Save, RefreshCw, Variable } from 'lucide-react' import { api, type Credentials } from '@/lib/api' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { useSyncPrompt } from '@/contexts/SyncContext' +import { useSyncNotification } from '@/contexts/SyncContext' export function EnvironmentSettings() { const queryClient = useQueryClient() - const showSyncPrompt = useSyncPrompt() + const showSyncNotification = useSyncNotification() const { data: credentials, isLoading, error, refetch } = useQuery({ queryKey: ['credentials'], @@ -31,7 +31,7 @@ export function EnvironmentSettings() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['credentials'] }) setHasChanges(false) - showSyncPrompt() + showSyncNotification() }, }) diff --git a/web/src/pages/settings/Files.tsx b/web/src/pages/settings/Files.tsx index 3ffe0388..86e6ff09 100644 --- a/web/src/pages/settings/Files.tsx +++ b/web/src/pages/settings/Files.tsx @@ -4,11 +4,11 @@ import { Plus, Trash2, Save, RefreshCw, FolderSync, ArrowRight } from 'lucide-re import { api, type Credentials } from '@/lib/api' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { useSyncPrompt } from '@/contexts/SyncContext' +import { useSyncNotification } from '@/contexts/SyncContext' export function FilesSettings() { const queryClient = useQueryClient() - const showSyncPrompt = useSyncPrompt() + const showSyncNotification = useSyncNotification() const { data: credentials, isLoading, error, refetch } = useQuery({ queryKey: ['credentials'], @@ -31,7 +31,7 @@ export function FilesSettings() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['credentials'] }) setHasChanges(false) - showSyncPrompt() + showSyncNotification() }, }) diff --git a/web/src/pages/settings/SSH.tsx b/web/src/pages/settings/SSH.tsx index 6be88ed2..5437d87c 100644 --- a/web/src/pages/settings/SSH.tsx +++ b/web/src/pages/settings/SSH.tsx @@ -3,11 +3,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Save, RefreshCw, Key, Check, AlertTriangle } from 'lucide-react' import { api, type SSHSettings } from '@/lib/api' import { Button } from '@/components/ui/button' -import { useSyncPrompt } from '@/contexts/SyncContext' +import { useSyncNotification } from '@/contexts/SyncContext' export function SSHSettings() { const queryClient = useQueryClient() - const showSyncPrompt = useSyncPrompt() + const showSyncNotification = useSyncNotification() const { data: sshSettings, isLoading, error, refetch } = useQuery({ queryKey: ['sshSettings'], @@ -35,7 +35,7 @@ export function SSHSettings() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sshSettings'] }) setHasChanges(false) - showSyncPrompt() + showSyncNotification() }, }) diff --git a/web/src/pages/settings/Scripts.tsx b/web/src/pages/settings/Scripts.tsx index 3e2a1b25..0118027a 100644 --- a/web/src/pages/settings/Scripts.tsx +++ b/web/src/pages/settings/Scripts.tsx @@ -4,11 +4,11 @@ import { Save, RefreshCw } from 'lucide-react' import { api, type Scripts } from '@/lib/api' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { useSyncPrompt } from '@/contexts/SyncContext' +import { useSyncNotification } from '@/contexts/SyncContext' export function ScriptsSettings() { const queryClient = useQueryClient() - const showSyncPrompt = useSyncPrompt() + const showSyncNotification = useSyncNotification() const { data: scripts, isLoading, error, refetch } = useQuery({ queryKey: ['scripts'], @@ -31,7 +31,7 @@ export function ScriptsSettings() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['scripts'] }) setHasChanges(false) - showSyncPrompt() + showSyncNotification() }, })