import * as fs from 'fs/promises'; import * as path from 'path'; import axios from 'axios'; import { parseMarkdownChecklist, updateMarkdownChecklist, ChecklistItem } from '../utils/markdown-parser.js'; import { Logger } from '../utils/logger.js'; export interface PlanWatcherConfig { watchPath: string; projectId: string; apiUrl: string; apiKey: string; } interface CloudChecklistItem extends ChecklistItem { status?: 'active' | 'archived'; } export class PlanWatcher { private config: PlanWatcherConfig; private lastContentHash: string = ''; private activeTaskId: string | null = null; private planPath: string; private archiveDir: string; private isSyncing: boolean = false; private lastSyncedAt: number = 0; constructor(config: PlanWatcherConfig) { this.config = config; this.planPath = path.join(this.config.watchPath, 'IMPLEMENTATION_PLAN.md'); this.archiveDir = path.join(this.config.watchPath, '.rigstate', 'plans'); } async checkForChanges(targetPath?: string) { if (this.isSyncing) return; const currentPlanPath = targetPath || this.planPath; try { const exists = await fs.access(currentPlanPath).then(() => true).catch(() => false); if (!exists) return; const content = await fs.readFile(currentPlanPath, 'utf-8'); const contentHash = this.hash(content); // 1. Detect Task ID (Affinity Lock) const idMatch = content.match(/\*\*(?:Task ID|ID):\*\*\s*(.+?)(?:\s|\n|$)/i); const detectedId = idMatch ? idMatch[1].trim() : null; if (detectedId && detectedId !== this.activeTaskId) { this.activeTaskId = detectedId; Logger.info(`PlanWatcher: Task Affinity Lock -> ${this.activeTaskId}`); await this.syncFromCloud(); return; } if (!this.activeTaskId) return; // 2. Race Condition Guard: Only sync if file changed OR if cloud sync hasn't happened in 5 mins const now = Date.now(); if (contentHash !== this.lastContentHash || (now - this.lastSyncedAt > 300000)) { this.lastContentHash = contentHash; await this.syncToArchive(content); await this.syncToCloud(content); } } catch (error: any) { Logger.error(`PlanWatcher Error: ${error.message}`); } } private hash(str: string): string { // More robust hash including length and specific strategy return `v2:${str.length}:${this.activeTaskId}:${str.slice(0, 50)}`; } /** * SYNC FROM CLOUD (Bi-directional Logic) * Filters out archived items before writing to local file. */ async syncFromCloud() { if (!this.activeTaskId || this.isSyncing) return; this.isSyncing = true; try { const uuid = await this.resolveUuid(this.activeTaskId); if (!uuid) return; const response = await axios.get(`${this.config.apiUrl}/api/v1/roadmap?project_id=${this.config.projectId}`, { headers: { Authorization: `Bearer ${this.config.apiKey}` } }); const task = response.data.data.roadmap.find((t: any) => t.id === uuid); if (task) { Logger.info(`[PlanWatcher] Syncing ${this.activeTaskId} (Cloud -> Local)`); // Filter out archived items for the local file view const activeChecklist = (task.checklist || []).filter((item: CloudChecklistItem) => item.status !== 'archived'); let content = task.implementation_plan; // Sync Logic: If implementation_plan exists, use it but update checklist to match cloud state if (content) { content = updateMarkdownChecklist(content, activeChecklist); } else if (task.checklist && task.checklist.length > 0) { // Fallback: Reconstruct basic file if content is missing content = `# Implementation Plan: ${task.title}\n**ID:** ${this.activeTaskId}\n\n## Checklist\n`; activeChecklist.forEach((item: ChecklistItem) => { content += `- [${item.checked ? 'x' : ' '}] ${item.text}\n`; }); } if (content) { await fs.writeFile(this.planPath, content); this.lastContentHash = this.hash(content); this.lastSyncedAt = Date.now(); } } } catch (error: any) { Logger.error(`Failed sync from cloud: ${error.message}`); } finally { this.isSyncing = false; } } /** * SYNC TO CLOUD (Intelligent Sletting / Delete-to-Archive) */ private async syncToCloud(content: string) { if (!this.activeTaskId || this.isSyncing) return; this.isSyncing = true; try { const uuid = await this.resolveUuid(this.activeTaskId); if (!uuid) return; // 1. Fetch CURRENT cloud state to perform diff const getResponse = await axios.get(`${this.config.apiUrl}/api/v1/roadmap?project_id=${this.config.projectId}`, { headers: { Authorization: `Bearer ${this.config.apiKey}` } }); const cloudTask = getResponse.data.data.roadmap.find((t: any) => t.id === uuid); const cloudChecklist: CloudChecklistItem[] = cloudTask?.checklist || []; // 2. Parse LOCAL checklist const localChecklist = parseMarkdownChecklist(content); // 3. Intelligent Merging (Sovereign Deletion) const finalChecklist: CloudChecklistItem[] = []; // Map existing cloud items for lookup const cloudMap = new Map( cloudChecklist.map(item => [item.text, item]) ); // Process Local Items (Keep them active) for (const localItem of localChecklist) { finalChecklist.push({ text: localItem.text, checked: localItem.checked, status: 'active' }); cloudMap.delete(localItem.text); } Logger.info(`[PlanWatcher] Syncing ${localChecklist.length} local items to cloud.`); // Process Remaining Cloud Items (Mark as archived if missing locally) for (const remainingItem of cloudMap.values()) { if (remainingItem.status !== 'archived') { Logger.info(`[PlanWatcher] 🏛️ Archiving item (Sovereign Delete): "${remainingItem.text}"`); } finalChecklist.push({ ...remainingItem, status: 'archived' // This is the "Delete-to-Archive" logic }); } // 4. Update Cloud State await axios.post(`${this.config.apiUrl}/api/v1/roadmap/update-checklist`, { step_id: uuid, checklist: finalChecklist, implementation_plan: content, project_id: this.config.projectId }, { headers: { 'Authorization': `Bearer ${this.config.apiKey}` } }); this.lastSyncedAt = Date.now(); Logger.info(`[PlanWatcher] Sovereign Sync -> ${this.activeTaskId} (Local -> Cloud) [Audit items preserved]`); } catch (error: any) { Logger.error(`Sync fail: ${error.response?.data?.error || error.message}`); } finally { this.isSyncing = false; } } private async syncToArchive(content: string) { if (!this.activeTaskId) return; try { await fs.mkdir(this.archiveDir, { recursive: true }); const safeName = this.activeTaskId.replace(/[^a-z0-9]/gi, '-').toLowerCase(); const archivePath = path.join(this.archiveDir, `${safeName}.md`); await fs.writeFile(archivePath, content); } catch (error: any) { Logger.error(`Archive fail: ${error.message}`); } } private async resolveUuid(taskId: string): Promise { if (taskId.length > 30 && taskId.includes('-')) return taskId; try { const lookup = await axios.get(`${this.config.apiUrl}/api/v1/roadmap?project_id=${this.config.projectId}`, { headers: { Authorization: `Bearer ${this.config.apiKey}` }, validateStatus: null // Handle 404/etc manually }).catch(() => null); if (!lookup) return null; const roadmap = Array.isArray(lookup.data?.data?.roadmap) ? lookup.data.data.roadmap : []; const task = roadmap.find((t: any) => `T-${t.step_number}` === taskId || t.step_number.toString() === taskId || t.id === taskId ); return task?.id || null; } catch { return null; } } }