Spaces:
Running
Running
| // stateManager.js | |
| let db = null; | |
| export const initDB = (firebaseDB) => { | |
| db = firebaseDB; | |
| }; | |
| const activeProjects = new Map(); | |
| export const StateManager = { | |
| /** | |
| * GET PROJECT | |
| * Hydrates from DB if not in memory. | |
| * Flattens { info, thumbnail, state } into a usable Memory Object. | |
| */ | |
| getProject: async (projectId) => { | |
| // 1. Try Memory | |
| if (activeProjects.has(projectId)) { | |
| return activeProjects.get(projectId); | |
| } | |
| // 2. Try Firebase (Hydration) | |
| if (db) { | |
| try { | |
| console.log(`[StateManager] 🟡 Hydrating project ${projectId} from DB...`); | |
| // We fetch the root because the Server AI needs EVERYTHING (context, history) to function. | |
| // If this was a client-side Dashboard, we would only fetch /info. | |
| const snapshot = await db.ref(`projects/${projectId}`).once('value'); | |
| if (snapshot.exists()) { | |
| const dbData = snapshot.val(); | |
| // FLATTEN DB STRUCTURE -> MEMORY STRUCTURE | |
| const memoryObject = { | |
| ...dbData.info, // Title, stats, description | |
| thumbnail: dbData.thumbnail?.url || null, | |
| // State might be undefined if just created, so default it | |
| commandQueue: dbData.state?.commandQueue || [], | |
| workerHistory: dbData.state?.workerHistory || [], | |
| pmHistory: dbData.state?.pmHistory || [], | |
| failureCount: dbData.state?.failureCount || 0, | |
| gdd: dbData.state?.gdd || null, | |
| // CRITICAL: Initialize lastActive to NOW so it doesn't expire immediately | |
| lastActive: Date.now(), | |
| lastUpdated: Date.now() | |
| }; | |
| // Load into Memory | |
| activeProjects.set(projectId, memoryObject); | |
| console.log(`[StateManager] 🟢 Project ${projectId} hydrated.`); | |
| return memoryObject; | |
| } | |
| } catch (err) { | |
| console.error(`[StateManager] Error reading DB for ${projectId}:`, err); | |
| } | |
| } | |
| return null; | |
| }, | |
| /** | |
| * UPDATE PROJECT | |
| * Updates Memory immediately. | |
| * Performs granular updates to DB (separating Info vs State). | |
| */ | |
| updateProject: async (projectId, data) => { | |
| let current = activeProjects.get(projectId); | |
| // Safety check: ensure we have base state | |
| if (!current) { | |
| current = await StateManager.getProject(projectId) || { | |
| commandQueue: [], workerHistory: [], pmHistory: [], failureCount: 0 | |
| }; | |
| } | |
| const timestamp = Date.now(); | |
| // 1. Update Memory (Flat Merge) | |
| const newData = { | |
| ...current, | |
| ...data, | |
| // CRITICAL: Initialize lastActive to NOW so it doesn't expire immediately | |
| lastActive: Date.now(), | |
| lastUpdated: Date.now() | |
| }; | |
| activeProjects.set(projectId, newData); | |
| // 2. Update Firebase (Nested Split) | |
| if (db) { | |
| const updates = {}; | |
| // We assume 'data' contains the specific fields that changed. | |
| // However, to be safe and robust, we map the current state to the correct buckets. | |
| // Bucket: Info | |
| if (data.status || data.stats || data.title) { | |
| updates[`projects/${projectId}/info/status`] = newData.status; | |
| if (newData.lastUpdated) updates[`projects/${projectId}/info/lastUpdated`] = newData.lastUpdated; | |
| // Add other info fields if they are mutable | |
| } | |
| // Bucket: State (History, Queue, GDD) - This is the most frequent update | |
| if (data.workerHistory || data.pmHistory || data.commandQueue || data.failureCount || data.gdd) { | |
| updates[`projects/${projectId}/state/workerHistory`] = newData.workerHistory; | |
| updates[`projects/${projectId}/state/pmHistory`] = newData.pmHistory; | |
| updates[`projects/${projectId}/state/commandQueue`] = newData.commandQueue; | |
| updates[`projects/${projectId}/state/failureCount`] = newData.failureCount; | |
| if (newData.gdd) updates[`projects/${projectId}/state/gdd`] = newData.gdd; | |
| } | |
| // Bucket: Thumbnail (Only update if explicitly passed in 'data', avoiding re-upload of massive string) | |
| if (data.thumbnail) { | |
| updates[`projects/${projectId}/thumbnail`] = {url: data.thumbnail}; | |
| } | |
| // Perform the update | |
| if (Object.keys(updates).length > 0) { | |
| db.ref().update(updates).catch(err => { | |
| console.error(`[StateManager] 🔴 DB Write Failed for ${projectId}:`, err); | |
| }); | |
| } | |
| } | |
| return newData; | |
| }, | |
| queueCommand: async (projectId, input) => { | |
| const project = await StateManager.getProject(projectId); | |
| if (!project) return; | |
| let command = null; | |
| if (typeof input === 'object' && input.type && input.payload) { | |
| command = input; | |
| } | |
| else if (typeof input === 'string') { | |
| const rawResponse = input; | |
| if (rawResponse.includes("[ASK_PM:")) return; | |
| if (rawResponse.includes("[GENERATE_IMAGE:") && !rawResponse.includes("```")) return; | |
| const codeMatch = rawResponse.match(/```(?:lua|luau)?([\s\S]*?)```/i); | |
| const readScriptMatch = rawResponse.match(/\[READ_SCRIPT:\s*(.*?)\]/); | |
| const readHierarchyMatch = rawResponse.match(/\[READ_HIERARCHY:\s*(.*?)\]/); | |
| const readLogsMatch = rawResponse.includes("[READ_LOGS]"); | |
| if (codeMatch) command = { type: "EXECUTE", payload: codeMatch[1].trim() }; | |
| else if (readScriptMatch) command = { type: "READ_SCRIPT", payload: readScriptMatch[1].trim() }; | |
| else if (readHierarchyMatch) command = { type: "READ_HIERARCHY", payload: readHierarchyMatch[1].trim() }; | |
| else if (readLogsMatch) command = { type: "READ_LOGS", payload: null }; | |
| } | |
| if (command) { | |
| project.commandQueue.push(command); | |
| console.log(`[${projectId}] Queued Action: ${command.type}`); | |
| // Sync queue to DB | |
| await StateManager.updateProject(projectId, { commandQueue: project.commandQueue }); | |
| } | |
| }, | |
| popCommand: async (projectId) => { | |
| const project = await StateManager.getProject(projectId); | |
| if (!project || !project.commandQueue.length) return null; | |
| const command = project.commandQueue.shift(); | |
| // Sync queue to DB | |
| await StateManager.updateProject(projectId, { commandQueue: project.commandQueue }); | |
| return command; | |
| }, | |
| cleanupMemory: () => { | |
| const now = Date.now(); | |
| const FOUR_HOURS = 4 * 60 * 60 * 1000; | |
| let count = 0; | |
| for (const [id, data] of activeProjects.entries()) { | |
| // FIX: If lastActive is missing/undefined/0, reset it to NOW. | |
| // This prevents the "54 year old project" bug where (now - 0) > 4 hours. | |
| if (!data.lastActive) { | |
| console.warn(`[StateManager] ⚠️ Project ${id} missing timestamp. Healing data...`); | |
| data.lastActive = now; | |
| continue; // Skip deleting this round | |
| } | |
| if (now - data.lastActive > FOUR_HOURS) { | |
| console.log(`[StateManager] 🧹 Removing expired project: ${id}`); | |
| activeProjects.delete(id); | |
| count++; | |
| } | |
| } | |
| if (count > 0) { | |
| console.log(`[StateManager] 🗑️ Cleaned ${count} projects from memory.`); | |
| } | |
| return count; | |
| } | |
| /* cleanupMemory: () => { | |
| const now = Date.now(); | |
| const FOUR_HOURS = 4 * 60 * 60 * 1000; | |
| let count = 0; | |
| for (const [id, data] of activeProjects.entries()) { | |
| if (now - (data.lastActive || 0) > FOUR_HOURS) { | |
| activeProjects.delete(id); | |
| count++; | |
| } | |
| } | |
| console.log(`[StateManager] 🧹 Memory Cleanup: Removed ${count} inactive projects.`); | |
| return count; | |
| } */ | |
| }; |