Pepguy commited on
Commit
45ae1d9
·
verified ·
1 Parent(s): 1a1f90b

Update app.js

Browse files
Files changed (1) hide show
  1. app.js +64 -205
app.js CHANGED
@@ -20,16 +20,11 @@ const app = 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",
@@ -44,182 +39,146 @@ const VALID_ACTIONS = [
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
57
  "create_complex_mesh": "create_meshpart",
58
  "load_mesh": "create_meshpart",
59
-
60
- // Map common movement terms
61
  "move": "move_to",
62
  "position": "move_to",
63
  "rotate": "rotate_to",
64
  "set_rotation": "rotate_to",
65
-
66
- // Scripting synonyms
67
  "add_script": "create_script",
68
  "insert_script": "create_script",
69
  "modify_script": "edit_script",
70
  "append_script": "attach_script",
71
-
72
- // Hierarchy
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-2.5-pro", // 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: [],
@@ -231,7 +190,6 @@ app.post("/projects", async (req, res) => {
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);
@@ -239,193 +197,96 @@ app.post("/projects", async (req, res) => {
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-2.5-pro",
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;
@@ -447,16 +308,14 @@ async function runProject(project) {
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;
 
20
  app.use(cors());
21
  app.use(bodyParser.json({ limit: "2mb" }));
22
 
 
 
 
23
  const projects = new Map();
 
24
  const simpleId = (prefix = "") => prefix + Math.random().toString(36).slice(2, 9);
25
 
26
  /**
27
+ * STRICT VOCABULARY - Matched to Plugin
 
28
  */
29
  const VALID_ACTIONS = [
30
  "create_part",
 
39
  "set_parent",
40
  "delete",
41
  "weld",
42
+ "list_workspace",
43
  "finish"
44
  ];
45
 
 
 
 
 
46
  const ACTION_SYNONYMS = {
 
47
  "create_complex_mesh": "create_meshpart",
48
  "load_mesh": "create_meshpart",
 
 
49
  "move": "move_to",
50
  "position": "move_to",
51
  "rotate": "rotate_to",
52
  "set_rotation": "rotate_to",
 
 
53
  "add_script": "create_script",
54
  "insert_script": "create_script",
55
  "modify_script": "edit_script",
56
  "append_script": "attach_script",
 
 
57
  "group": "create_model",
58
  "parent": "set_parent",
59
  "set_parent_to": "set_parent",
60
  "join": "weld",
61
+ "union": "create_model",
 
 
62
  "create_union": "create_model",
63
+ "create_rig": "create_model",
64
+ "add_bone": "create_part"
65
  };
 
66
 
 
 
 
 
67
  function validateAndPrepareTasks(arr) {
68
  if (!Array.isArray(arr)) throw new Error("Model output must be a JSON array of tasks.");
69
  return arr.map((t) => {
70
  if (!t || typeof t !== "object") throw new Error("Each task must be an object.");
71
  if (!t.action) throw new Error("Each task must have an action field.");
72
 
 
73
  let actionRaw = String(t.action).trim();
74
  const actionLower = actionRaw.toLowerCase();
75
 
 
76
  let canonicalAction = null;
 
77
  if (VALID_ACTIONS.includes(actionRaw)) canonicalAction = actionRaw;
 
78
  if (!canonicalAction) {
79
  canonicalAction = VALID_ACTIONS.find(a => a.toLowerCase() === actionLower) || null;
80
  }
 
81
  if (!canonicalAction && ACTION_SYNONYMS[actionLower]) {
82
  canonicalAction = ACTION_SYNONYMS[actionLower];
83
  }
84
 
85
  if (!canonicalAction) {
86
+ throw new Error(`Invalid action '${actionRaw}'. Supported: ${VALID_ACTIONS.join(", ")}`);
 
87
  }
88
 
89
  const id = t.id || simpleId("cmd_");
90
  const params = t.params && typeof t.params === "object" ? t.params : {};
 
91
  return { id, action: canonicalAction, originalAction: actionRaw, params, target_ref: t.target_ref || null };
92
  });
93
  }
94
 
 
 
 
 
95
  async function generateTasks(project) {
 
96
  const historySample = (project.history || []).slice(-20).map(h => ({
97
  commandId: h.commandId,
98
  status: h.status,
99
  message: h.message ? (typeof h.message === "string" ? (h.message.length > 120 ? h.message.slice(0, 117) + "..." : h.message) : null) : null,
100
  originalAction: h.originalAction || null,
101
  target_ref: h.target_ref || null,
 
102
  }));
103
  const refsKeys = Object.keys(project._refs || {}).slice(0, 200);
 
 
 
104
 
105
  const stateSummary = {
106
  projectId: project.id,
107
+ refsSummary: refsKeys,
108
+ recentHistory: historySample
 
 
 
109
  };
110
 
 
111
  const systemPrompt = `
112
+ You are a precise Roblox Studio Assistant.
113
+ Allowed actions: ${VALID_ACTIONS.join(", ")}.
114
 
115
+ RULES:
116
+ 1. **References:** When creating an object, ALWAYS assign a unique "target_ref" (e.g., "ref_wall_1").
117
+ 2. **Chaining:** When moving/modifying later, use "target_ref": "ref_wall_1".
118
+ 3. **Hierarchy:** To put a Part inside a Model: create_model (ref_grp) -> create_part (ref_part) -> set_parent (target_ref: ref_part, params: { parent_ref: ref_grp }).
119
+ 4. **Math:** Use numbers. Position {x,y,z}. Rotation {x,y,z} (degrees).
120
+ 5. **Output:** Return ONLY a JSON Array.
121
 
122
+ EXISTING STATE:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  ${JSON.stringify(stateSummary, null, 2)}
124
 
125
+ User Request:
126
+ ${JSON.stringify({ description: project.description })}
127
  `;
 
128
 
129
+ // Using 1.5-pro as the closest valid "Pro" model.
130
+ // If you strictly need "gemini-2.5-pro", allow the string, but note it might 404 if not enabled on your account.
131
  const resp = await ai.models.generateContent({
132
+ model: "gemini-2.5-pro",
133
  contents: systemPrompt,
134
+ generationConfig: { responseMimeType: "application/json" }
135
  });
136
 
137
+ // === FIX: Robust Response Text Extraction ===
138
+ let text = "";
139
+ if (resp && typeof resp.text === 'string') {
140
+ // SDK returned text property directly
141
+ text = resp.text;
142
+ } else if (resp && typeof resp.text === 'function') {
143
+ // SDK returned text method
144
+ text = resp.text();
145
+ } else if (resp?.response?.text) {
146
+ // Nested response object
147
+ text = typeof resp.response.text === 'function' ? resp.response.text() : resp.response.text;
148
+ } else {
149
+ // Fallback for raw data structure
150
+ text = resp?.data?.candidates?.[0]?.content?.parts?.[0]?.text || "";
151
+ }
152
+ // ============================================
153
+
154
  let arr;
155
  try {
156
+ // Sanitize markdown if still present
157
+ const cleanText = text.replace(/```json/g, "").replace(/```/g, "").trim();
158
+ arr = JSON.parse(cleanText);
159
  } catch (err) {
160
+ // Fallback extraction
161
  const start = text.indexOf("[");
162
  const end = text.lastIndexOf("]");
163
  if (start >= 0 && end > start) {
164
  arr = JSON.parse(text.slice(start, end + 1));
165
  } else {
166
+ console.error("AI Output was:", text);
167
+ throw new Error("Could not parse JSON from model output.");
168
  }
169
  }
170
 
171
  return validateAndPrepareTasks(arr);
172
  }
173
 
174
+ // --- ENDPOINTS ---
175
+
 
 
176
  app.post("/projects", async (req, res) => {
177
  try {
 
 
 
 
178
  const projectId = req.body.projectId || simpleId("proj_");
 
 
179
  const project = {
180
  id: projectId,
181
+ description: req.body.description || "New Project",
182
  status: "queued",
183
  tasks: [],
184
  commandQueue: [],
 
190
 
191
  projects.set(projectId, project);
192
 
 
193
  runProject(project).catch((err) => {
194
  project.status = "error";
195
  console.error("runProject error:", err);
 
197
 
198
  return res.status(202).json({ projectId, status: "accepted" });
199
  } catch (err) {
 
200
  return res.status(500).json({ error: err.message });
201
  }
202
  });
203
 
 
 
 
 
204
  app.post("/projects/:projectId/prompt", async (req, res) => {
205
  const project = projects.get(req.params.projectId);
206
  if (!project) return res.status(404).json({ error: "project not found" });
207
+
208
  const prompt = req.body.prompt;
209
+ project.description += `\nUser Update: ${prompt}`;
210
 
211
  try {
212
+ const newTasks = await generateTasks({ ...project, description: prompt });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  project.tasks.push(...newTasks);
 
214
  project.commandQueue.push(...newTasks);
 
215
  return res.json({ appended: newTasks.length, tasks: newTasks });
216
  } catch (err) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  return res.status(500).json({ error: err.message });
218
  }
219
  });
220
 
 
 
 
221
  app.get("/projects/:projectId/next", (req, res) => {
222
  const project = projects.get(req.params.projectId);
223
  if (!project) return res.status(404).json({ error: "project not found" });
224
 
225
  if (project.commandQueue.length > 0) {
226
+ return res.json(project.commandQueue.shift());
 
227
  }
228
  return res.status(204).end();
229
  });
230
 
 
 
 
 
231
  app.post("/projects/:projectId/result", (req, res) => {
232
  const project = projects.get(req.params.projectId);
233
  if (!project) return res.status(404).json({ error: "project not found" });
234
 
235
  const { commandId, status, message, extra, target_ref } = req.body;
236
+
237
+ // History recording
 
238
  const entry = {
239
  commandId,
240
  status,
241
  message: message || null,
242
  extra: extra || null,
243
  target_ref: target_ref || null,
 
244
  ts: Date.now()
245
  };
 
 
 
 
 
246
  project.history = project.history || [];
247
  project.history.push(entry);
248
 
 
249
  if (target_ref && status === "ok") {
250
  project._refs = project._refs || {};
251
+ project._refs[target_ref] = { commandId, ts: Date.now() };
252
  }
253
 
 
254
  const waiter = project.pendingResults.get(commandId);
255
  if (waiter) {
256
  project.pendingResults.delete(commandId);
257
+ waiter.resolve({ status, message, target_ref });
258
  return res.json({ ok: true });
259
  }
260
 
261
+ return res.json({ ok: true });
 
262
  });
263
 
 
 
 
264
  app.get("/status/:projectId", (req, res) => {
265
  const project = projects.get(req.params.projectId);
266
  if (!project) return res.status(404).json({ error: "project not found" });
267
  return res.json({
268
  id: project.id,
269
  status: project.status,
270
+ queued: project.commandQueue.length,
271
+ refs: project._refs || {}
 
 
272
  });
273
  });
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  async function runProject(project) {
276
  if (project.running) return;
277
  project.running = true;
278
  project.status = "running";
279
 
280
  try {
 
281
  if (!project.tasks || project.tasks.length === 0) {
282
  project.tasks = await generateTasks(project);
283
  }
 
 
 
 
284
  project.commandQueue.push(...project.tasks);
285
 
286
+ // Blocking wait logic
 
287
  for (const task of project.tasks) {
 
 
 
288
  const result = await new Promise((resolve, reject) => {
289
+ const timeoutMs = 3 * 60 * 1000;
290
  let timedOut = false;
291
  const timeout = setTimeout(() => {
292
  timedOut = true;
 
308
  });
309
  });
310
 
 
311
  if (result.status !== "ok") {
312
  project.status = "failed";
313
+ throw new Error(`Command ${task.id} failed: ${result.message}`);
314
  }
315
  }
 
316
  project.status = "complete";
317
  } catch (err) {
318
+ console.error("Project run error:", err);
319
  if (project.status !== "failed") project.status = "error";
320
  } finally {
321
  project.running = false;