@saltcorn/copilot 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,8 @@ const {
34
34
  small,
35
35
  a,
36
36
  textarea,
37
+ tr,
38
+ td,
37
39
  } = require("@saltcorn/markup/tags");
38
40
  const { getState } = require("@saltcorn/data/db/state");
39
41
  const renderLayout = require("@saltcorn/markup/layout");
@@ -47,6 +49,37 @@ const {
47
49
  available_plugins_list,
48
50
  } = require("./prompts");
49
51
 
52
+ const doneTaskRowHtml = (task) =>
53
+ tr(
54
+ { "data-row-id": task.id },
55
+ td(task.body.name || ""),
56
+ td(task.body.description || ""),
57
+ td((task.body.depends_on || []).join(", ")),
58
+ td(task.body.priority || ""),
59
+ td("Done"),
60
+ td(
61
+ task.body.run_id
62
+ ? a(
63
+ {
64
+ target: "_blank",
65
+ href: `/view/Saltcorn%20Agent%20copilot?run_id=${task.body.run_id}`,
66
+ },
67
+ i({ class: "fas fa-external-link-alt" })
68
+ )
69
+ : ""
70
+ ),
71
+ td(""),
72
+ td(
73
+ button(
74
+ {
75
+ class: "btn btn-outline-danger btn-sm",
76
+ onclick: `view_post("${viewname}", "del_task", {id:${task.id}})`,
77
+ },
78
+ i({ class: "fas fa-trash-alt" })
79
+ )
80
+ )
81
+ );
82
+
50
83
  const makeTaskList = async (req) => {
51
84
  const rs = await MetaData.find(
52
85
  {
@@ -60,8 +93,18 @@ const makeTaskList = async (req) => {
60
93
  name: "settings",
61
94
  });
62
95
  const running = !!settings?.body?.running;
96
+ const stopping = !running && rs.some((t) => t.body.status === "Running");
97
+ const runningTask = rs.find((t) => t.body.status === "Running");
98
+ const statusText = runningTask
99
+ ? span(
100
+ "Running: ",
101
+ span({ class: "fw-bold" }, runningTask.body.name || "task")
102
+ )
103
+ : running
104
+ ? "Currently running"
105
+ : "Currently not running";
63
106
  const status = div(
64
- running ? "Currently running" : "Currently not running",
107
+ span({ id: "copilot-status-text" }, statusText),
65
108
  running
66
109
  ? button(
67
110
  {
@@ -73,22 +116,46 @@ const makeTaskList = async (req) => {
73
116
  )
74
117
  : button(
75
118
  {
119
+ id: "copilot-start-btn",
76
120
  class: "btn btn-success ms-2",
77
- onclick: `view_post("${viewname}", "start", {})`,
121
+ ...(stopping
122
+ ? { disabled: true }
123
+ : { onclick: `copilotStartRunning(this)` }),
78
124
  },
79
- i({ class: "fas fa-play me-1" }),
80
- "Start running now"
125
+ stopping
126
+ ? i({ class: "fas fa-spinner fa-spin me-1" })
127
+ : i({ class: "fas fa-play me-1" }),
128
+ stopping ? "Running..." : "Start running now"
81
129
  ),
130
+ stopping &&
131
+ span(
132
+ {
133
+ id: "copilot-stop-notice",
134
+ class:
135
+ "alert alert-warning alert-dismissible d-inline-block ms-2 py-1 px-2 mb-0",
136
+ style: "font-size:0.875rem",
137
+ },
138
+ button({
139
+ type: "button",
140
+ class: "btn-close btn-sm",
141
+ "data-bs-dismiss": "alert",
142
+ }),
143
+ "The current task will complete, then the queue stops. No new tasks will be started."
144
+ ),
82
145
  button(
83
146
  {
147
+ id: "copilot-run-next-btn",
84
148
  class: "btn btn-outline-success ms-2",
85
- onclick: `press_store_button(this);view_post("${viewname}", "run_task", {})`,
149
+ style: running || stopping ? "display:none" : "",
150
+ onclick: `copilotRunNext(this)`,
86
151
  },
87
152
  i({ class: "fas fa-play me-1" }),
88
153
  "Run next task"
89
154
  )
90
155
  );
91
156
  if (rs.length) {
157
+ const runningOnLoad =
158
+ !stopping && rs.some((t) => t.body.status === "Running");
92
159
  return div(
93
160
  { class: "mt-2" },
94
161
  status,
@@ -126,7 +193,8 @@ const makeTaskList = async (req) => {
126
193
  : button(
127
194
  {
128
195
  class: "btn btn-outline-success btn-sm",
129
- onclick: `press_store_button(this);view_post("${viewname}", "run_task", {id:${r.id}})`,
196
+ "data-task-run": r.id,
197
+ onclick: `copilotRunTask(this,${r.id})`,
130
198
  },
131
199
  i({ class: "fas fa-play" })
132
200
  ),
@@ -159,33 +227,261 @@ const makeTaskList = async (req) => {
159
227
  ],
160
228
  {
161
229
  "To do": rs.filter(
162
- (t) => !t.body.status || t.body.status === "To do"
230
+ (t) =>
231
+ !t.body.status ||
232
+ t.body.status === "To do" ||
233
+ t.body.status === "Running"
163
234
  ),
164
- Running: rs.filter((t) => t.body.status === "Running"),
165
235
  Done: rs.filter((t) => t.body.status === "Done"),
166
236
  },
167
237
  { grouped: true }
168
238
  ),
169
- rs.some((t) => t.body.status === "Running")
170
- ? script(
171
- domReady(`
172
- (function() {
173
- function pollTasks() {
174
- var spinners = document.querySelectorAll('.task-spinner[data-task-id]');
175
- if (!spinners.length) return;
176
- var ids = Array.from(spinners).map(function(el) { return el.getAttribute('data-task-id'); });
177
- view_post(${JSON.stringify(
178
- viewname
179
- )}, 'task_status', { ids: ids }, function(resp) {
180
- if (resp && resp.any_done) {
181
- location.reload();
239
+ script(`
240
+ function copilotInitStopping() {
241
+ const startBtn = document.getElementById('copilot-start-btn');
242
+ const noticeEl = document.getElementById('copilot-stop-notice');
243
+ const runNextBtn = document.getElementById('copilot-run-next-btn');
244
+ const movedToDone = copilotDoneIds();
245
+ const poll = () => {
246
+ view_post(${JSON.stringify(viewname)}, 'tasks_poll', {}, (resp) => {
247
+ if (!resp || !resp.tasks) return;
248
+ let hasRunning = false;
249
+ for (const task of resp.tasks) {
250
+ if (task.status === 'Done' && !movedToDone.has(task.id)) {
251
+ movedToDone.add(task.id);
252
+ view_post(${JSON.stringify(
253
+ viewname
254
+ )}, 'task_row_done', {id: task.id}, (rowResp) => {
255
+ copilotAppendDoneRow(task.id, rowResp);
256
+ });
257
+ } else if (task.status === 'Running') {
258
+ hasRunning = true;
259
+ copilotShowSpinner(task.id);
260
+ }
261
+ }
262
+ if (hasRunning) {
263
+ setTimeout(poll, 3000);
182
264
  } else {
183
- setTimeout(pollTasks, 3000);
265
+ if (noticeEl) noticeEl.remove();
266
+ const statusTextEl = document.getElementById('copilot-status-text');
267
+ if (statusTextEl) statusTextEl.textContent = 'Currently not running';
268
+ if (startBtn) {
269
+ startBtn.disabled = false;
270
+ startBtn.innerHTML = '<i class="fas fa-play me-1"></i>Start running now';
271
+ startBtn.onclick = () => copilotStartRunning(startBtn);
272
+ }
273
+ if (runNextBtn) runNextBtn.style.display = '';
184
274
  }
185
275
  });
276
+ };
277
+ setTimeout(poll, 1000);
278
+ }
279
+
280
+ function copilotAppendDoneRow(taskId, rowResp) {
281
+ const row = document.querySelector('tr[data-row-id="' + taskId + '"]');
282
+ if (row) row.remove();
283
+ const doneHeader = Array.from(document.querySelectorAll('h4.list-group-header'))
284
+ .find(h => h.textContent.trim() === 'Done');
285
+ if (doneHeader && rowResp && rowResp.html) {
286
+ const tbody = doneHeader.closest('tr').parentNode;
287
+ const tmp = document.createElement('tbody');
288
+ tmp.innerHTML = rowResp.html;
289
+ tbody.appendChild(tmp.firstChild);
186
290
  }
187
- setTimeout(pollTasks, 3000);
188
- })();
291
+ }
292
+
293
+ function copilotRunTask(btn, taskId) {
294
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
295
+ btn.disabled = true;
296
+ const runNextBtn = document.getElementById('copilot-run-next-btn');
297
+ const startBtn = document.getElementById('copilot-start-btn');
298
+ if (runNextBtn) runNextBtn.disabled = true;
299
+ if (startBtn) startBtn.disabled = true;
300
+ const row = document.querySelector('tr[data-row-id="' + taskId + '"]');
301
+ const taskName = row ? (row.cells[0]?.textContent?.trim() || '') : '';
302
+ const statusTextEl = document.getElementById('copilot-status-text');
303
+ if (statusTextEl) statusTextEl.innerHTML =
304
+ 'Running: <span class="fw-bold">' + (taskName || 'task') + '</span>';
305
+ view_post(${JSON.stringify(viewname)}, 'run_task', {id: taskId}, () => {
306
+ const poll = () => {
307
+ view_post(${JSON.stringify(
308
+ viewname
309
+ )}, 'task_status', {ids: [String(taskId)]}, (resp) => {
310
+ if (resp && resp.any_done) {
311
+ if (statusTextEl) statusTextEl.textContent = 'Currently not running';
312
+ if (runNextBtn) runNextBtn.disabled = false;
313
+ if (startBtn) startBtn.disabled = false;
314
+ view_post(${JSON.stringify(
315
+ viewname
316
+ )}, 'task_row_done', {id: taskId}, (rowResp) => {
317
+ copilotAppendDoneRow(taskId, rowResp);
318
+ });
319
+ } else {
320
+ setTimeout(poll, 3000);
321
+ }
322
+ });
323
+ };
324
+ setTimeout(poll, 3000);
325
+ });
326
+ }
327
+
328
+ function copilotDoneIds() {
329
+ const ids = new Set();
330
+ const doneHeader = Array.from(document.querySelectorAll('h4.list-group-header'))
331
+ .find(h => h.textContent.trim() === 'Done');
332
+ if (doneHeader) {
333
+ let sib = doneHeader.closest('tr').nextElementSibling;
334
+ while (sib) {
335
+ const id = sib.getAttribute('data-row-id');
336
+ if (id) ids.add(Number(id));
337
+ sib = sib.nextElementSibling;
338
+ }
339
+ }
340
+ return ids;
341
+ }
342
+
343
+ function copilotShowSpinner(taskId) {
344
+ const row = document.querySelector('tr[data-row-id="' + taskId + '"]');
345
+ if (row && !row.querySelector('.task-spinner')) {
346
+ const runBtn = row.querySelector('[data-task-run]');
347
+ if (runBtn) {
348
+ runBtn.closest('td').innerHTML =
349
+ '<span class="task-spinner" data-task-id="' + taskId + '">' +
350
+ '<i class="fas fa-spinner fa-spin text-warning"></i></span>';
351
+ }
352
+ }
353
+ }
354
+
355
+ function copilotRunNext(btn) {
356
+ btn.disabled = true;
357
+ const originalHtml = btn.innerHTML;
358
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Running...';
359
+ const firstRunBtn = document.querySelector('[data-task-run]');
360
+ const optimisticId = firstRunBtn ? Number(firstRunBtn.getAttribute('data-task-run')) : null;
361
+ if (optimisticId) copilotShowSpinner(optimisticId);
362
+ const movedToDone = copilotDoneIds();
363
+ view_post(${JSON.stringify(viewname)}, 'run_task', {}, () => {
364
+ const poll = () => {
365
+ view_post(${JSON.stringify(viewname)}, 'tasks_poll', {}, (resp) => {
366
+ if (!resp || !resp.tasks) return;
367
+ let hasRunning = false;
368
+ const runningIds = new Set(resp.tasks.filter(t => t.status === 'Running').map(t => t.id));
369
+ if (optimisticId && !runningIds.has(optimisticId) && !movedToDone.has(optimisticId)) {
370
+ const staleRow = document.querySelector('tr[data-row-id="' + optimisticId + '"]');
371
+ const staleSpinner = staleRow?.querySelector('.task-spinner');
372
+ if (staleSpinner) staleSpinner.closest('td').innerHTML =
373
+ '<button class="btn btn-outline-success btn-sm" data-task-run="' + optimisticId + '" onclick="copilotRunTask(this,' + optimisticId + ')"><i class="fas fa-play"></i></button>';
374
+ }
375
+ for (const task of resp.tasks) {
376
+ if (task.status === 'Done' && !movedToDone.has(task.id)) {
377
+ movedToDone.add(task.id);
378
+ view_post(${JSON.stringify(
379
+ viewname
380
+ )}, 'task_row_done', {id: task.id}, (rowResp) => {
381
+ copilotAppendDoneRow(task.id, rowResp);
382
+ });
383
+ } else if (task.status === 'Running') {
384
+ hasRunning = true;
385
+ copilotShowSpinner(task.id);
386
+ }
387
+ }
388
+ if (hasRunning) setTimeout(poll, 3000);
389
+ else {
390
+ btn.disabled = false;
391
+ btn.innerHTML = originalHtml;
392
+ }
393
+ });
394
+ };
395
+ setTimeout(poll, 500);
396
+ });
397
+ }
398
+
399
+ function copilotStartRunning(btn) {
400
+ btn.disabled = true;
401
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Running...';
402
+ const runNextBtn = document.getElementById('copilot-run-next-btn');
403
+ const statusTextEl = document.getElementById('copilot-status-text');
404
+ if (runNextBtn) runNextBtn.style.display = 'none';
405
+ if (statusTextEl) statusTextEl.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Running';
406
+ let stopped = false;
407
+ let stopNotice = null;
408
+ const stopBtn = document.createElement('button');
409
+ stopBtn.className = 'btn btn-danger ms-2';
410
+ stopBtn.innerHTML = '<i class="fas fa-stop me-1"></i>Stop';
411
+ stopBtn.onclick = () => {
412
+ stopped = true;
413
+ stopBtn.disabled = true;
414
+ view_post(${JSON.stringify(viewname)}, 'stop', {});
415
+ if (!stopNotice) {
416
+ stopNotice = document.createElement('span');
417
+ stopNotice.className = 'alert alert-warning alert-dismissible d-inline-block ms-2 py-1 px-2 mb-0';
418
+ stopNotice.style.fontSize = '0.875rem';
419
+ stopNotice.innerHTML =
420
+ '<button type="button" class="btn-close btn-sm" data-bs-dismiss="alert"></button>' +
421
+ 'The current task will complete, then the queue stops. No new tasks will be started.';
422
+ stopBtn.insertAdjacentElement('afterend', stopNotice);
423
+ }
424
+ };
425
+ btn.insertAdjacentElement('afterend', stopBtn);
426
+ view_post(${JSON.stringify(viewname)}, 'start', {}, () => {
427
+ const movedToDone = copilotDoneIds();
428
+ const poll = () => {
429
+ view_post(${JSON.stringify(viewname)}, 'tasks_poll', {}, (resp) => {
430
+ if (!resp || !resp.tasks) return;
431
+ let hasPending = false;
432
+ let hasRunning = false;
433
+ for (const task of resp.tasks) {
434
+ if (task.status === 'Done' && !movedToDone.has(task.id)) {
435
+ movedToDone.add(task.id);
436
+ view_post(${JSON.stringify(
437
+ viewname
438
+ )}, 'task_row_done', {id: task.id}, (rowResp) => {
439
+ copilotAppendDoneRow(task.id, rowResp);
440
+ });
441
+ } else if (task.status === 'Running') {
442
+ hasRunning = true;
443
+ copilotShowSpinner(task.id);
444
+ if (statusTextEl) statusTextEl.innerHTML =
445
+ 'Running: <span class="fw-bold">' + (task.name || 'task') + '</span>';
446
+ } else if (task.status !== 'Done') {
447
+ hasPending = true;
448
+ }
449
+ }
450
+ if (hasRunning || (!stopped && hasPending)) {
451
+ setTimeout(poll, 3000);
452
+ } else {
453
+ stopBtn.remove();
454
+ if (stopNotice) { stopNotice.remove(); stopNotice = null; }
455
+ btn.disabled = false;
456
+ btn.innerHTML = '<i class="fas fa-play me-1"></i>Start running now';
457
+ if (statusTextEl) statusTextEl.textContent = 'Currently not running';
458
+ if (runNextBtn) runNextBtn.style.display = '';
459
+ }
460
+ });
461
+ };
462
+ setTimeout(poll, 1000);
463
+ });
464
+ }
465
+ `),
466
+ stopping
467
+ ? script(domReady(`copilotInitStopping();`))
468
+ : runningOnLoad
469
+ ? script(
470
+ domReady(`
471
+ const runNextBtn = document.getElementById('copilot-run-next-btn');
472
+ const startBtn = document.getElementById('copilot-start-btn');
473
+ if (runNextBtn) runNextBtn.disabled = true;
474
+ if (startBtn) startBtn.disabled = true;
475
+ const pollTasks = () => {
476
+ const spinners = document.querySelectorAll('.task-spinner[data-task-id]');
477
+ if (!spinners.length) return;
478
+ const ids = Array.from(spinners).map(el => el.getAttribute('data-task-id'));
479
+ view_post(${JSON.stringify(viewname)}, 'task_status', {ids}, (resp) => {
480
+ if (resp && resp.any_done) location.reload();
481
+ else setTimeout(pollTasks, 3000);
482
+ });
483
+ };
484
+ setTimeout(pollTasks, 3000);
189
485
  `)
190
486
  )
191
487
  : "",
@@ -211,17 +507,13 @@ const makeTaskList = async (req) => {
211
507
  ),
212
508
  script(
213
509
  domReady(`
214
- (function() {
215
- function poll() {
216
- view_post(${JSON.stringify(
217
- viewname
218
- )}, 'planning_status', {}, function(resp) {
219
- if (resp && !resp.planning) location.reload();
220
- else setTimeout(poll, 3000);
221
- });
222
- }
223
- setTimeout(poll, 3000);
224
- })();
510
+ const poll = () => {
511
+ view_post(${JSON.stringify(viewname)}, 'planning_status', {}, (resp) => {
512
+ if (resp && !resp.planning) location.reload();
513
+ else setTimeout(poll, 3000);
514
+ });
515
+ };
516
+ setTimeout(poll, 3000);
225
517
  `)
226
518
  )
227
519
  );
@@ -328,22 +620,38 @@ Important trigger planning rules:
328
620
  Important view planning rules:
329
621
  * Each task must create exactly one view. Never put two or more views in the same task. Edit, Show, and List for the same table are always three separate tasks with three separate names, descriptions, and dependencies.
330
622
  * Do NOT plan separate tasks for "create" and "edit" on the same table. In Saltcorn, a single Edit view handles both (no id = create, id present = edit). One task, one Edit view, description says "create and edit".
331
- * Edit, Show, and List views for a table form a natural group and should normally each be planned as their own task. A List without a Show leaves users with no way to inspect details; omit or adjust only when the requirements explicitly say the data is read-only or not editable. When all three are planned, the ordering of tasks must be: Edit and Show first (in either order, they are independent of each other), then List last, because the List depends on both.
332
- * A List view task must depend on the Edit view task and the Show view task for the same table (if both exist), since its rows link to them. Set depends_on accordingly.
623
+ * Edit, Show, and List views for a table always go together as three separate tasks. Whenever you plan a List view AND a Show view for the same table, you MUST also plan an Edit view for that table a List without an Edit leaves users unable to create or modify records. Only omit the Edit view when the requirements explicitly say the data is read-only.
624
+ * The three tasks must be ordered: Edit and Show first (independent of each other, in any order), List last. The List task MUST list both the Edit task and the Show task in its depends_on without exception. If you plan a List that depends on neither, that is a bug in the plan.
625
+ * Before finalising the plan, for every List view task, verify that its depends_on includes the corresponding Edit task and the corresponding Show task (if they exist). If either is missing, add it.
333
626
  * When a List view links to a Show view or Edit view, the task description must say: "Add a viewlink column to [view_name] for the current row" — not just "link each row". This wording makes it unambiguous that a viewlink column must be added to the list for each target view.
334
627
  * In general, if a view embeds or links to another view, the linked view's task must be listed as a dependency.
335
628
  * When a table has foreign key fields referencing the users table, the task description must explicitly state for each one whether it is an ownership field (automatically set from the logged-in user, omit from the form) or a selector field (the user picks a value, include a selector in the form). Example: "user_id records the owner and is set automatically; shared_with_user_id must have a user selector."
336
629
  * For FK fields that represent a parent context (e.g. trip_id on packing_items), always include the field as a normal selector in the Edit view form. Do NOT say to omit it. Saltcorn automatically pre-fills the selector from the URL query parameter when the view is opened from a parent context, and the user can select it manually when the view is used standalone.
337
630
  * For every task that creates a view, include the exact view name in the task description. View names must be lowercase, snake_case, unique across all tasks in the plan, and descriptive enough to identify the table and purpose — for example 'packing_items_edit' rather than just 'edit'.
338
-
339
631
  Important user account rules:
340
- * The platform (Saltcorn) provides a built-in user account system with login, registration, and session management. Do NOT plan any tasks for user registration, login pages, password management, or authentication flows — these are already handled by the platform.
632
+ * The platform (Saltcorn) provides a built-in user account system with login, registration, and session management. Do NOT plan any tasks for user registration, login pages, password management, authentication flows, or email verification — these are already handled by the platform. Users register at /auth/signup and log in at /auth/login.
341
633
  * User identity is always available as the logged-in user. Ownership fields (FK to users) are set automatically from the session; no custom logic is needed.
342
634
  * If a requirement mentions "user accounts", "secure login", "saving data per user", "user-specific data", or "sharing between users", treat it as already satisfied by the platform's built-in user system. Do not generate any task in response to such a requirement.
343
635
 
636
+ Important role rules:
637
+ * Every view and page task description MUST state the min_role explicitly, e.g. "Set min_role to admin (1)." or "Set min_role to user (80).". Never omit it.
638
+ * Role values: admin=1, staff=40, user=80, public=100. Use the value that matches who will use the view or page — admin for management, staff for staff-only, user for logged-in users (clients, members, etc.), public only when the view or page must be accessible without login.
639
+
640
+ Important home page rules:
641
+ * Every role should land on the right page after visiting /. Plan a single task "Set home pages by role" that depends on all relevant page tasks and configures home_page_by_role for every role in one step.
642
+ * Role IDs: public=100, user=80, staff=40, admin=1.
643
+ * Landing/marketing page (public-facing intro): min_role must be 100 (public). It MUST include visible links to /auth/login (Log in) and /auth/signup (Create an account). Set as home for role 100 (public).
644
+ * If there is an admin dashboard page, set it as home for role 1 (admin).
645
+ * If there is a dashboard or main page for regular users or staff, set it as home for role 80 (user) and/or role 40 (staff) as appropriate.
646
+ * The "Set home pages by role" task description must list every role→page mapping explicitly using the exact page names planned in this task list, e.g.: "Set home_page_by_role: public (100) → landing, user (80) → client_dashboard, staff (40) → staff_dashboard, admin (1) → app_admin_dashboard." Never use "admin_dashboard" as a page name — it is reserved by the platform.
647
+
344
648
  Important plugin rules:
345
649
  * If multiple plugins need to be installed, combine them ALL into a single task named "Install plugins" that lists every required plugin name. Do NOT create a separate task per plugin.
346
650
 
651
+ Important dependency rules:
652
+ * Every name in a task's depends_on MUST exactly match the name field of another task in the same plan_tasks call. Never reference a name that is not present in the tasks array — not a concept, not a table name, not a made-up label. If you find yourself writing a depends_on entry whose name does not appear as a task name in the list, either add the missing task or remove the dependency.
653
+ * Before calling plan_tasks, mentally verify: for every task, every name in its depends_on array appears as the name of another task in the array.
654
+
347
655
  Important schema/table rules:
348
656
  * The database schema is already fully designed and implemented before task planning begins. ALL tables and fields needed by the application already exist. Do NOT plan any tasks that create tables, add fields, modify fields, or change the schema in any way. If you find yourself writing a task whose output is a table or a field, delete it — that work is already done.
349
657
  * Ownership behaviour (auto-setting a FK-to-users field from the logged-in user) is configured in the Edit view, not in the database. Do not create tasks for it at the schema level.
@@ -379,13 +687,27 @@ Before finalising the plan, you may call get_view_config for any existing view y
379
687
 
380
688
  const planCall = toolCalls.find((tc) => tc.tool_name === "plan_tasks");
381
689
  if (planCall) {
382
- for (const task of planCall.input.tasks)
690
+ const tasks = planCall.input.tasks;
691
+ const plannedNames = new Set(tasks.map((t) => t.name).filter(Boolean));
692
+
693
+ for (const task of tasks) {
694
+ const validDeps = (task.depends_on || []).filter((nm) => {
695
+ if (!plannedNames.has(nm)) {
696
+ getState().log(
697
+ 2,
698
+ `AppConstructor: dropping phantom dependency "${nm}" from task "${task.name}" — no such task in plan`
699
+ );
700
+ return false;
701
+ }
702
+ return true;
703
+ });
383
704
  await MetaData.create({
384
705
  type: "CopilotConstructMgr",
385
706
  name: "task",
386
- body: task,
707
+ body: { ...task, depends_on: validDeps },
387
708
  user_id: userId,
388
709
  });
710
+ }
389
711
  break;
390
712
  }
391
713
 
@@ -477,12 +799,14 @@ const del_task = async (table_id, viewname, config, body, { req, res }) => {
477
799
  };
478
800
  const run_task = async (table_id, viewname, config, body, { req, res }) => {
479
801
  const reqUser = req?.user;
480
- if (body.id)
802
+ if (body.id) {
481
803
  runTask(body.id, { user: reqUser, __: req.__ }).catch((e) =>
482
804
  console.error("run_task error", e)
483
805
  );
484
- else runNextTask(true).catch((e) => console.error("run_task error", e));
485
- return { json: { reload_page: true } };
806
+ return { json: { success: true } };
807
+ }
808
+ runNextTask(true).catch((e) => console.error("run_task error", e));
809
+ return { json: { success: true } };
486
810
  };
487
811
 
488
812
  const planning_status = async (
@@ -499,6 +823,35 @@ const planning_status = async (
499
823
  return { json: { planning: !!planning } };
500
824
  };
501
825
 
826
+ const tasks_poll = async (table_id, viewname, config, body, { req, res }) => {
827
+ const tasks = await MetaData.find({
828
+ type: "CopilotConstructMgr",
829
+ name: "task",
830
+ });
831
+ return {
832
+ json: {
833
+ tasks: tasks.map((t) => ({
834
+ id: t.id,
835
+ name: t.body.name,
836
+ status: t.body.status || "To do",
837
+ run_id: t.body.run_id,
838
+ })),
839
+ },
840
+ };
841
+ };
842
+
843
+ const task_row_done = async (
844
+ table_id,
845
+ viewname,
846
+ config,
847
+ body,
848
+ { req, res }
849
+ ) => {
850
+ const task = await MetaData.findOne({ id: body.id });
851
+ if (!task) return { json: { html: "" } };
852
+ return { json: { html: doneTaskRowHtml(task) } };
853
+ };
854
+
502
855
  const task_status = async (table_id, viewname, config, body, { req, res }) => {
503
856
  const ids = body.ids || [];
504
857
  const tasks = await MetaData.find({
@@ -524,7 +877,7 @@ const start = async (table_id, viewname, config, body, { req, res }) => {
524
877
  body: { running: true },
525
878
  });
526
879
  runNextTask().catch((e) => console.error("start error", e));
527
- return { json: { reload_page: true } };
880
+ return { json: { success: true } };
528
881
  };
529
882
  const stop = async (table_id, viewname, config, body, { req, res }) => {
530
883
  const settings = await MetaData.findOne({
@@ -539,7 +892,7 @@ const stop = async (table_id, viewname, config, body, { req, res }) => {
539
892
  name: "settings",
540
893
  body: { running: false },
541
894
  });
542
- return { json: { reload_page: true } };
895
+ return { json: { success: true } };
543
896
  };
544
897
 
545
898
  const mark_done_task = async (
@@ -578,6 +931,8 @@ const task_routes = {
578
931
  run_task,
579
932
  planning_status,
580
933
  task_status,
934
+ tasks_poll,
935
+ task_row_done,
581
936
  start,
582
937
  stop,
583
938
  };
@@ -46,12 +46,13 @@ const task_tool = {
46
46
  type: "array",
47
47
  items: {
48
48
  type: "object",
49
- required: ["requirement", "priority"],
49
+ required: ["name", "description", "priority", "depends_on"],
50
50
  additionalProperties: false,
51
51
  properties: {
52
52
  name: {
53
53
  type: "string",
54
- description: "A short name for the task",
54
+ description:
55
+ "A short unique name for the task (snake_case). Every other task that depends on this task must use exactly this name in their depends_on array.",
55
56
  },
56
57
  description: {
57
58
  type: "string",
@@ -65,7 +66,7 @@ const task_tool = {
65
66
  depends_on: {
66
67
  type: "array",
67
68
  description:
68
- "The names of the tasks that must be completed before this tasks can be started",
69
+ "Names of tasks in THIS plan that must complete before this task starts. Every name listed here MUST exactly match the name of another task in this same plan_tasks call. Never reference a task name that is not present in the tasks array.",
69
70
  items: {
70
71
  type: "string",
71
72
  },
@@ -78,4 +79,4 @@ const task_tool = {
78
79
  },
79
80
  };
80
81
 
81
- module.exports = { requirements_tool,task_tool };
82
+ module.exports = { requirements_tool, task_tool };
@@ -159,6 +159,13 @@ const submit_specs = async (table_id, viewname, config, body, { req, res }) => {
159
159
  user_id: req.user?.id || undefined,
160
160
  body: spec,
161
161
  });
162
+ return {
163
+ json: {
164
+ success: "ok",
165
+ notify: "Specification saved",
166
+ notify_type: "success",
167
+ },
168
+ };
162
169
  };
163
170
 
164
171
  const virtual_triggers = () => {