Pepguy commited on
Commit
9903c38
·
verified ·
1 Parent(s): 61084ae

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +308 -89
app.js CHANGED
@@ -1,4 +1,6 @@
1
- // server.js - ALIGNED WITH PLUGIN & FIXED SYNTAX
 
 
2
  // Run: GEMINI_API_KEY=your_key node server.js
3
 
4
  import express from "express";
@@ -18,18 +20,21 @@ const app = express();
18
  app.use(cors());
19
  app.use(bodyParser.json({ limit: "2mb" }));
20
 
 
 
 
21
  const projects = new Map();
22
 
23
  const simpleId = (prefix = "") => prefix + Math.random().toString(36).slice(2, 9);
24
 
25
  /**
26
- * STRICT VOCABULARY
27
- * Only actions that exist in the Plugin's ACTION_HANDLERS are allowed here.
28
  */
29
  const VALID_ACTIONS = [
30
  "create_part",
31
  "create_model",
32
- "create_meshpart",
33
  "create_script",
34
  "attach_script",
35
  "edit_script",
@@ -39,13 +44,13 @@ const VALID_ACTIONS = [
39
  "set_parent",
40
  "delete",
41
  "weld",
42
- "list_workspace",
43
  "finish"
44
  ];
45
 
46
  /**
47
- * SYNONYM MAPPING
48
- * Maps what the AI *wants* to say to what the Plugin *can* do.
49
  */
50
  const ACTION_SYNONYMS = {
51
  // Map complex mesh requests to simple meshparts
@@ -68,182 +73,396 @@ const ACTION_SYNONYMS = {
68
  "group": "create_model",
69
  "parent": "set_parent",
70
  "set_parent_to": "set_parent",
71
- "join": "weld", // Fixed: JS comment syntax
72
 
73
- // Unsupported fallbacks (Safety)
74
- "union": "create_model", // Fixed: JS comment syntax
75
  "create_union": "create_model",
76
- "create_rig": "create_model" // Fixed: JS comment syntax
 
77
  };
 
78
 
 
 
 
 
79
  function validateAndPrepareTasks(arr) {
80
  if (!Array.isArray(arr)) throw new Error("Model output must be a JSON array of tasks.");
81
  return arr.map((t) => {
82
  if (!t || typeof t !== "object") throw new Error("Each task must be an object.");
83
  if (!t.action) throw new Error("Each task must have an action field.");
84
 
 
85
  let actionRaw = String(t.action).trim();
86
  const actionLower = actionRaw.toLowerCase();
87
 
 
88
  let canonicalAction = null;
 
89
  if (VALID_ACTIONS.includes(actionRaw)) canonicalAction = actionRaw;
90
-
91
- // Case-insensitive check
92
  if (!canonicalAction) {
93
  canonicalAction = VALID_ACTIONS.find(a => a.toLowerCase() === actionLower) || null;
94
  }
95
-
96
- // Synonym check
97
  if (!canonicalAction && ACTION_SYNONYMS[actionLower]) {
98
  canonicalAction = ACTION_SYNONYMS[actionLower];
99
  }
100
 
101
  if (!canonicalAction) {
102
- throw new Error(`Invalid action '${actionRaw}'. Supported: ${VALID_ACTIONS.join(", ")}`);
 
103
  }
104
 
105
  const id = t.id || simpleId("cmd_");
106
  const params = t.params && typeof t.params === "object" ? t.params : {};
107
-
108
- return {
109
- id,
110
- action: canonicalAction,
111
- originalAction: actionRaw,
112
- params,
113
- target_ref: t.target_ref || null
114
- };
115
  });
116
  }
117
 
 
 
 
 
118
  async function generateTasks(project) {
119
- const historySample = (project.history || []).slice(-15).map(h => ({
 
 
120
  status: h.status,
121
- action: h.originalAction || "unknown",
122
- msg: h.message ? h.message.slice(0, 50) : "ok"
 
 
123
  }));
124
-
 
 
125
  const status = project.status || "unknown";
126
 
 
 
 
 
 
 
 
 
 
 
127
  const systemPrompt = `
128
- You are a Roblox Studio Assistant. Convert user descriptions into JSON tasks.
129
-
130
- AVAILABLE ACTIONS:
131
- ${VALID_ACTIONS.join(", ")}
132
-
133
- RULES:
134
- 1. **References:** When creating an object, ALWAYS assign a unique "target_ref" (e.g., "ref_wall_1").
135
- 2. **Chaining:** When moving/modifying that object later, use "target_ref": "ref_wall_1" in the params.
136
- 3. **Hierarchy:** To put a Part inside a Model:
137
- - Task 1: create_model (target_ref: "ref_grp")
138
- - Task 2: create_part (target_ref: "ref_part")
139
- - Task 3: set_parent (target_ref: "ref_part", params: { parent_ref: "ref_grp" })
140
- 4. **Math:** Use numbers for positions. Roblox Y is Up.
141
- 5. **Output:** Return ONLY a JSON Array. No markdown.
142
-
143
- Current Context:
144
- ${JSON.stringify({ history: historySample, status }, null, 2)}
145
-
146
- User Request:
147
- ${JSON.stringify({ description: project.description })}
 
 
 
 
 
148
  `;
 
149
 
150
- // Use gemini-1.5-flash or 2.0-flash depending on what you have access to.
151
- // 1.5-flash is very stable.
152
  const resp = await ai.models.generateContent({
153
- model: "gemini-2.5-pro",
154
  contents: systemPrompt,
155
- generationConfig: { responseMimeType: "application/json" }
156
  });
157
 
158
  const text = resp?.text ? resp.text() : (resp?.response?.text() ?? "");
159
-
160
  let arr;
161
  try {
162
- const cleanText = text.replace(/```json/g, "").replace(/```/g, "").trim();
163
- arr = JSON.parse(cleanText);
164
  } catch (err) {
165
- console.error("Model Output:", text);
166
- throw new Error("Failed to parse JSON from AI.");
 
 
 
 
 
 
167
  }
168
 
169
  return validateAndPrepareTasks(arr);
170
  }
171
 
172
- // --- EXPRESS ROUTES ---
173
-
 
 
174
  app.post("/projects", async (req, res) => {
175
  try {
 
 
 
 
176
  const projectId = req.body.projectId || simpleId("proj_");
 
 
177
  const project = {
178
  id: projectId,
179
- description: req.body.description || "New Project",
180
  status: "queued",
181
  tasks: [],
182
  commandQueue: [],
183
  pendingResults: new Map(),
184
- history: [],
185
- _refs: {}
 
186
  };
 
187
  projects.set(projectId, project);
188
- runProject(project);
 
 
 
 
 
 
189
  return res.status(202).json({ projectId, status: "accepted" });
190
  } catch (err) {
 
191
  return res.status(500).json({ error: err.message });
192
  }
193
  });
194
 
 
 
 
 
195
  app.post("/projects/:projectId/prompt", async (req, res) => {
196
  const project = projects.get(req.params.projectId);
197
  if (!project) return res.status(404).json({ error: "project not found" });
198
-
199
  const prompt = req.body.prompt;
200
- project.description += `\nUser Update: ${prompt}`;
201
-
202
  try {
203
- const newTasks = await generateTasks({ ...project, description: prompt });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  project.tasks.push(...newTasks);
 
205
  project.commandQueue.push(...newTasks);
206
- return res.json({ tasks: newTasks });
207
- } catch(e) {
208
- return res.status(500).json({error: e.message});
 
 
209
  }
210
  });
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  app.get("/projects/:projectId/next", (req, res) => {
213
  const project = projects.get(req.params.projectId);
214
  if (!project) return res.status(404).json({ error: "project not found" });
 
215
  if (project.commandQueue.length > 0) {
216
- return res.json(project.commandQueue.shift());
 
217
  }
218
  return res.status(204).end();
219
  });
220
 
 
 
 
 
221
  app.post("/projects/:projectId/result", (req, res) => {
222
  const project = projects.get(req.params.projectId);
223
  if (!project) return res.status(404).json({ error: "project not found" });
224
-
225
- const { commandId, status, message, target_ref } = req.body;
226
-
227
- project.history.push({ commandId, status, message, timestamp: Date.now() });
228
-
229
- if (project.pendingResults.has(commandId)) {
230
- project.pendingResults.get(commandId).resolve({ status, message, target_ref });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  project.pendingResults.delete(commandId);
 
 
232
  }
233
-
234
- return res.json({ ok: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  });
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  async function runProject(project) {
238
- if (project.tasks.length === 0 && project.description) {
239
- try {
240
- project.tasks = await generateTasks(project);
241
- project.commandQueue.push(...project.tasks);
242
- } catch (e) {
243
- console.error("Gen Error:", e);
244
- return;
245
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  }
247
  }
248
 
249
- app.listen(PORT, () => console.log(`BloxBuddy Brain running on ${PORT}`));
 
 
 
1
+ // server.js
2
+ // ESM Node (package.json should contain "type": "module")
3
+ // Install: npm install express body-parser cors @google/genai
4
  // Run: GEMINI_API_KEY=your_key node server.js
5
 
6
  import express from "express";
 
20
  app.use(cors());
21
  app.use(bodyParser.json({ limit: "2mb" }));
22
 
23
+ /**
24
+ * In-memory store (prototype). For production, replace with persistent storage.
25
+ */
26
  const projects = new Map();
27
 
28
  const simpleId = (prefix = "") => prefix + Math.random().toString(36).slice(2, 9);
29
 
30
  /**
31
+ * === CHANGED SECTION START ===
32
+ * Action vocabulary Strictly aligned with what plugin.lua actually supports.
33
  */
34
  const VALID_ACTIONS = [
35
  "create_part",
36
  "create_model",
37
+ "create_meshpart",
38
  "create_script",
39
  "attach_script",
40
  "edit_script",
 
44
  "set_parent",
45
  "delete",
46
  "weld",
47
+ "list_workspace", // Plugin calls this workspaceSnapshot
48
  "finish"
49
  ];
50
 
51
  /**
52
+ * Map of alternate action names -> canonical action.
53
+ * FIX: Removed unsupported synonyms (like create_rig) and mapped them to supported ones.
54
  */
55
  const ACTION_SYNONYMS = {
56
  // Map complex mesh requests to simple meshparts
 
73
  "group": "create_model",
74
  "parent": "set_parent",
75
  "set_parent_to": "set_parent",
76
+ "join": "weld",
77
 
78
+ // Unsupported fallbacks (Safety Mappings)
79
+ "union": "create_model", // Fallback: Group them instead of Unioning
80
  "create_union": "create_model",
81
+ "create_rig": "create_model", // Fallback: Just make a model container
82
+ "add_bone": "create_part" // Fallback: Make a part instead of a bone
83
  };
84
+ /** === CHANGED SECTION END === */
85
 
86
+ /**
87
+ * Validate/prepare model output tasks (ensure allowed actions, add ids).
88
+ * Also maps synonyms to canonical actions (records the originalAction in the returned object).
89
+ */
90
  function validateAndPrepareTasks(arr) {
91
  if (!Array.isArray(arr)) throw new Error("Model output must be a JSON array of tasks.");
92
  return arr.map((t) => {
93
  if (!t || typeof t !== "object") throw new Error("Each task must be an object.");
94
  if (!t.action) throw new Error("Each task must have an action field.");
95
 
96
+ // Normalize action: trim and coerce to string
97
  let actionRaw = String(t.action).trim();
98
  const actionLower = actionRaw.toLowerCase();
99
 
100
+ // Map synonyms (case-insensitive)
101
  let canonicalAction = null;
102
+ // First try exact match against VALID_ACTIONS (case-sensitive)
103
  if (VALID_ACTIONS.includes(actionRaw)) canonicalAction = actionRaw;
104
+ // Then try case-insensitive match
 
105
  if (!canonicalAction) {
106
  canonicalAction = VALID_ACTIONS.find(a => a.toLowerCase() === actionLower) || null;
107
  }
108
+ // Then try synonyms map
 
109
  if (!canonicalAction && ACTION_SYNONYMS[actionLower]) {
110
  canonicalAction = ACTION_SYNONYMS[actionLower];
111
  }
112
 
113
  if (!canonicalAction) {
114
+ // Not recognized — include the offending action in the error to help debugging
115
+ throw new Error(`Invalid action from model: ${actionRaw}`);
116
  }
117
 
118
  const id = t.id || simpleId("cmd_");
119
  const params = t.params && typeof t.params === "object" ? t.params : {};
120
+ // Attach originalAction for traceability in history/logs
121
+ return { id, action: canonicalAction, originalAction: actionRaw, params, target_ref: t.target_ref || null };
 
 
 
 
 
 
122
  });
123
  }
124
 
125
+ /**
126
+ * generateTasks(project)
127
+ * Uses Gemini to convert description + project state into an ordered JSON array of tasks.
128
+ */
129
  async function generateTasks(project) {
130
+ // Summarize project state (concise)
131
+ const historySample = (project.history || []).slice(-20).map(h => ({
132
+ commandId: h.commandId,
133
  status: h.status,
134
+ message: h.message ? (typeof h.message === "string" ? (h.message.length > 120 ? h.message.slice(0, 117) + "..." : h.message) : null) : null,
135
+ originalAction: h.originalAction || null,
136
+ target_ref: h.target_ref || null,
137
+ ts: h.ts
138
  }));
139
+ const refsKeys = Object.keys(project._refs || {}).slice(0, 200);
140
+ const queuedCount = project.commandQueue ? project.commandQueue.length : 0;
141
+ const tasksCount = project.tasks ? project.tasks.length : 0;
142
  const status = project.status || "unknown";
143
 
144
+ const stateSummary = {
145
+ projectId: project.id,
146
+ status,
147
+ queuedCount,
148
+ tasksCount,
149
+ recentHistory: historySample,
150
+ refsSummary: refsKeys
151
+ };
152
+
153
+ /** === CHANGED SECTION START: Updated System Prompt === */
154
  const systemPrompt = `
155
+ You are a precise assistant that converts a high-level Roblox project description into an ordered JSON array of low-level editor tasks. Be spatially aware: reason about coordinates (x,y,z), sizes, rotations (euler degrees as {x,y,z}), orientations, and parent/child relationships. For every object you create, assign a stable "target_ref" string (short alphanumeric) that later tasks can reference.
156
+
157
+ Return ONLY valid JSON — a single array, no commentary, no extra text.
158
+
159
+ Allowed actions (use exactly these): ${VALID_ACTIONS.join(", ")}.
160
+
161
+ Task object shape:
162
+ {
163
+ "action": "<action>",
164
+ "params": { ... }, // numbers, strings, booleans, objects with numeric x,y,z, etc.
165
+ "target_ref": "<optional-ref>" // for create_* actions include this for reference
166
+ }
167
+
168
+ Important rules:
169
+ - Spatial parameters use explicit numeric fields: position: { x, y, z }, size: { x, y, z }, rotation: { x, y, z } (degrees).
170
+ - When referencing an earlier-created object, use params.target_ref exactly as the ref string you returned earlier.
171
+ - Do not reference Roblox-specific paths (like Workspace.Model.Part).
172
+ - Use only the allowed actions listed above (exactly those names).
173
+ - End the sequence with an action "finish" when the project is complete.
174
+
175
+ EXISTING PROJECT STATE (read-only; use to decide next tasks):
176
+ ${JSON.stringify(stateSummary, null, 2)}
177
+
178
+ Now produce tasks for this project:
179
+ ${JSON.stringify({ id: project.id, description: project.description })}
180
  `;
181
+ /** === CHANGED SECTION END === */
182
 
 
 
183
  const resp = await ai.models.generateContent({
184
+ model: "gemini-1.5-flash", // Changed to 1.5-flash for better stability
185
  contents: systemPrompt,
186
+ generationConfig: { responseMimeType: "application/json" } // Force JSON mode
187
  });
188
 
189
  const text = resp?.text ? resp.text() : (resp?.response?.text() ?? "");
 
190
  let arr;
191
  try {
192
+ arr = JSON.parse(text);
 
193
  } catch (err) {
194
+ // Try to extract substring if JSON mode fails (fallback)
195
+ const start = text.indexOf("[");
196
+ const end = text.lastIndexOf("]");
197
+ if (start >= 0 && end > start) {
198
+ arr = JSON.parse(text.slice(start, end + 1));
199
+ } else {
200
+ throw new Error("Could not parse JSON from model output: " + text.slice(0, 1000));
201
+ }
202
  }
203
 
204
  return validateAndPrepareTasks(arr);
205
  }
206
 
207
+ /**
208
+ * Create project endpoint
209
+ * Body: { description: string, projectId?: string }
210
+ */
211
  app.post("/projects", async (req, res) => {
212
  try {
213
+ const description = req.body.description;
214
+ if (!description || typeof description !== "string") {
215
+ return res.status(400).json({ error: "description string required in body" });
216
+ }
217
  const projectId = req.body.projectId || simpleId("proj_");
218
+ // Removed strict check if exists, just reset if it does, easier for plugin reconnects
219
+
220
  const project = {
221
  id: projectId,
222
+ description,
223
  status: "queued",
224
  tasks: [],
225
  commandQueue: [],
226
  pendingResults: new Map(),
227
+ running: false,
228
+ history: [],
229
+ _refs: {}
230
  };
231
+
232
  projects.set(projectId, project);
233
+
234
+ // Start background orchestrator (non-blocking)
235
+ runProject(project).catch((err) => {
236
+ project.status = "error";
237
+ console.error("runProject error:", err);
238
+ });
239
+
240
  return res.status(202).json({ projectId, status: "accepted" });
241
  } catch (err) {
242
+ console.error("projects POST error", err);
243
  return res.status(500).json({ error: err.message });
244
  }
245
  });
246
 
247
+ /**
248
+ * Tweak / prompt endpoint — includes project context so the model knows what has been done.
249
+ * Body: { prompt: string }
250
+ */
251
  app.post("/projects/:projectId/prompt", async (req, res) => {
252
  const project = projects.get(req.params.projectId);
253
  if (!project) return res.status(404).json({ error: "project not found" });
 
254
  const prompt = req.body.prompt;
255
+ if (!prompt || typeof prompt !== "string") return res.status(400).json({ error: "prompt required" });
256
+
257
  try {
258
+ // Create a temporary project context for the AI that includes the new prompt
259
+ const contextSummary = {
260
+ id: project.id,
261
+ status: project.status,
262
+ queuedCount: project.commandQueue.length,
263
+ tasksCount: project.tasks.length,
264
+ recentHistory: (project.history || []).slice(-50).map(h => ({ commandId: h.commandId, status: h.status, target_ref: h.target_ref, originalAction: h.originalAction })),
265
+ refsKeys: Object.keys(project._refs || {}).slice(0, 200)
266
+ };
267
+
268
+ const pseudoProject = {
269
+ id: project.id,
270
+ description: `${project.description}\n\n---\nContextSummary: ${JSON.stringify(contextSummary)}\n\nUserTweak: ${prompt}`
271
+ };
272
+
273
+ const newTasks = await generateTasks(pseudoProject);
274
+
275
+ // Append tasks to the project's task list
276
  project.tasks.push(...newTasks);
277
+ // Push directly to queue so the running loop picks them up
278
  project.commandQueue.push(...newTasks);
279
+
280
+ return res.json({ appended: newTasks.length, tasks: newTasks });
281
+ } catch (err) {
282
+ console.error("prompt endpoint error:", err);
283
+ return res.status(500).json({ error: err.message });
284
  }
285
  });
286
 
287
+ /**
288
+ * Direct model endpoint for manual calls (returns Gemini text)
289
+ * Body: { prompt: string }
290
+ */
291
+ app.post("/model", async (req, res) => {
292
+ try {
293
+ const prompt = req.body.prompt;
294
+ if (!prompt || typeof prompt !== "string") return res.status(400).json({ error: "prompt required" });
295
+
296
+ const resp = await ai.models.generateContent({
297
+ model: "gemini-1.5-flash",
298
+ contents: prompt,
299
+ });
300
+
301
+ const text = resp?.text ? resp.text() : "";
302
+ return res.json({ text });
303
+ } catch (err) {
304
+ console.error("/model error", err);
305
+ return res.status(500).json({ error: err.message });
306
+ }
307
+ });
308
+
309
+ /**
310
+ * Plugin polling: GET next command or 204 if none
311
+ */
312
  app.get("/projects/:projectId/next", (req, res) => {
313
  const project = projects.get(req.params.projectId);
314
  if (!project) return res.status(404).json({ error: "project not found" });
315
+
316
  if (project.commandQueue.length > 0) {
317
+ const cmd = project.commandQueue.shift();
318
+ return res.json(cmd);
319
  }
320
  return res.status(204).end();
321
  });
322
 
323
+ /**
324
+ * Plugin posts execution result. This records history and resolves waiters.
325
+ * Body: { commandId, status: "ok"|"error", message?: string, extra?: any, target_ref?: string }
326
+ */
327
  app.post("/projects/:projectId/result", (req, res) => {
328
  const project = projects.get(req.params.projectId);
329
  if (!project) return res.status(404).json({ error: "project not found" });
330
+
331
+ const { commandId, status, message, extra, target_ref } = req.body;
332
+ if (!commandId || !status) return res.status(400).json({ error: "commandId and status required" });
333
+
334
+ // Record in history
335
+ const entry = {
336
+ commandId,
337
+ status,
338
+ message: message || null,
339
+ extra: extra || null,
340
+ target_ref: target_ref || null,
341
+ originalAction: null,
342
+ ts: Date.now()
343
+ };
344
+
345
+ // Attempt to find the originalAction from project's tasks
346
+ const possibleTask = (project.tasks || []).find(t => t.id === commandId);
347
+ if (possibleTask && possibleTask.originalAction) entry.originalAction = possibleTask.originalAction;
348
+
349
+ project.history = project.history || [];
350
+ project.history.push(entry);
351
+
352
+ // Map target_ref if present and OK
353
+ if (target_ref && status === "ok") {
354
+ project._refs = project._refs || {};
355
+ project._refs[target_ref] = { commandId, extra, ts: Date.now() };
356
+ }
357
+
358
+ // Resolve pending waiter if present
359
+ const waiter = project.pendingResults.get(commandId);
360
+ if (waiter) {
361
  project.pendingResults.delete(commandId);
362
+ waiter.resolve({ status, message, extra, target_ref });
363
+ return res.json({ ok: true });
364
  }
365
+
366
+ // Otherwise accept and return
367
+ return res.json({ ok: true, note: "recorded result (no pending waiter)" });
368
+ });
369
+
370
+ /**
371
+ * Status endpoint
372
+ */
373
+ app.get("/status/:projectId", (req, res) => {
374
+ const project = projects.get(req.params.projectId);
375
+ if (!project) return res.status(404).json({ error: "project not found" });
376
+ return res.json({
377
+ id: project.id,
378
+ status: project.status,
379
+ queuedCommands: project.commandQueue.length,
380
+ totalTasks: project.tasks.length,
381
+ refs: project._refs || {},
382
+ historyLength: (project.history || []).length
383
+ });
384
  });
385
 
386
+ /**
387
+ * Simple list of projects (debug)
388
+ */
389
+ app.get("/projects", (req, res) => {
390
+ const list = Array.from(projects.values()).map((p) => ({
391
+ id: p.id,
392
+ description: p.description,
393
+ status: p.status,
394
+ queuedCommands: p.commandQueue.length,
395
+ tasks: p.tasks.length
396
+ }));
397
+ return res.json(list);
398
+ });
399
+
400
+ /**
401
+ * Orchestrator per project
402
+ * - If no tasks: generate from description
403
+ * - Iterate tasks, push to commandQueue, wait for plugin result, then continue
404
+ */
405
  async function runProject(project) {
406
+ if (project.running) return;
407
+ project.running = true;
408
+ project.status = "running";
409
+
410
+ try {
411
+ // If no tasks, generate initial plan
412
+ if (!project.tasks || project.tasks.length === 0) {
413
+ project.tasks = await generateTasks(project);
414
+ }
415
+
416
+ // Push all initial tasks to the queue
417
+ // (Note: In your original logic, you might have wanted to push them one by one.
418
+ // Here we push them to queue so the polling endpoint can serve them)
419
+ project.commandQueue.push(...project.tasks);
420
+
421
+ // If you want the orchestrator to BLOCK and wait for results before marking 'complete', we do this:
422
+ // (This loops through the local list, not the queue, because the queue gets drained by the plugin)
423
+ for (const task of project.tasks) {
424
+ // We need to wait until the plugin reports back for THIS task ID.
425
+ // The plugin might pick it up from commandQueue at any time.
426
+
427
+ const result = await new Promise((resolve, reject) => {
428
+ const timeoutMs = 3 * 60 * 1000; // 3 minutes
429
+ let timedOut = false;
430
+ const timeout = setTimeout(() => {
431
+ timedOut = true;
432
+ project.pendingResults.delete(task.id);
433
+ reject(new Error("Command timed out: " + task.id));
434
+ }, timeoutMs);
435
+
436
+ project.pendingResults.set(task.id, {
437
+ resolve: (payload) => {
438
+ if (timedOut) return;
439
+ clearTimeout(timeout);
440
+ resolve(payload);
441
+ },
442
+ reject: (err) => {
443
+ if (timedOut) return;
444
+ clearTimeout(timeout);
445
+ reject(err);
446
+ }
447
+ });
448
+ });
449
+
450
+ // If failure, mark and stop
451
+ if (result.status !== "ok") {
452
+ project.status = "failed";
453
+ throw new Error(`Command ${task.id} failed: ${result.message ?? "no message"}`);
454
+ }
455
+ }
456
+
457
+ project.status = "complete";
458
+ } catch (err) {
459
+ console.error("Project run error for", project.id, err);
460
+ if (project.status !== "failed") project.status = "error";
461
+ } finally {
462
+ project.running = false;
463
  }
464
  }
465
 
466
+ app.listen(PORT, () => {
467
+ console.log(`BloxBuddy backend listening on port ${PORT}`);
468
+ });