@saltcorn/copilot 0.8.2 → 0.8.3

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.
@@ -0,0 +1,1485 @@
1
+ const MetaData = require("@saltcorn/data/models/metadata");
2
+ const Table = require("@saltcorn/data/models/table");
3
+ const View = require("@saltcorn/data/models/view");
4
+ const Page = require("@saltcorn/data/models/page");
5
+ const Trigger = require("@saltcorn/data/models/trigger");
6
+ const Plugin = require("@saltcorn/data/models/plugin");
7
+ const {
8
+ div,
9
+ h6,
10
+ p,
11
+ span,
12
+ button,
13
+ i,
14
+ small,
15
+ ul,
16
+ li,
17
+ a,
18
+ pre,
19
+ } = require("@saltcorn/markup/tags");
20
+ const { mkTable } = require("@saltcorn/markup");
21
+ const { getState, features } = require("@saltcorn/data/db/state");
22
+ const db = require("@saltcorn/data/db");
23
+ const { viewname, tool_choice } = require("./common");
24
+ const { task_tool } = require("./tools");
25
+ const { runTask } = require("./run_task");
26
+ const { PromptGenerator } = require("./prompt-generator");
27
+
28
+ // ── Static client-side script ─────────────────────────────────────────────────
29
+
30
+ const phasesStaticScript = `<script>
31
+ const _phasesVn = ${JSON.stringify(viewname)};
32
+
33
+ function phasesStartPoll() {
34
+ const poll = () => {
35
+ view_post(_phasesVn, 'phases_status', {}, (resp) => {
36
+ if (resp && !resp.generating) {
37
+ view_post(_phasesVn, 'phases_html', {}, (r) => {
38
+ if (r && r.html) document.getElementById('phases-panel').innerHTML = r.html;
39
+ });
40
+ } else setTimeout(poll, 3000);
41
+ });
42
+ };
43
+ setTimeout(poll, 3000);
44
+ }
45
+
46
+ function _setPhaseParam(idx) {
47
+ const url = new URL(location.href);
48
+ if (idx !== null) url.searchParams.set('phase', idx);
49
+ else url.searchParams.delete('phase');
50
+ history.replaceState(null, '', url.toString());
51
+ }
52
+
53
+ window.copilotDelAllPhases = function() {
54
+ if (!confirm('Delete all phases and their tasks?')) return;
55
+ _setPhaseParam(null);
56
+ view_post(_phasesVn, 'del_all_phases', {}, () => {
57
+ view_post(_phasesVn, 'phases_html', {}, (r) => {
58
+ if (r && r.html) document.getElementById('phases-panel').innerHTML = r.html;
59
+ });
60
+ });
61
+ };
62
+
63
+ window.copilotGenPhases = function() {
64
+ const panel = document.getElementById('phases-panel');
65
+ const hasPhases = panel && panel.querySelector('.card');
66
+ if (hasPhases && !confirm('Regenerate phases? This will replace all existing phases and delete all phase tasks.')) return;
67
+ _setPhaseParam(null);
68
+ panel.innerHTML = '<p><i class="fas fa-spinner fa-spin me-2"></i>Generating phases, please wait...</p>';
69
+ view_post(_phasesVn, 'gen_phases', {}, () => {});
70
+ if (!window.dynamic_updates_cfg?.enabled) phasesStartPoll();
71
+ };
72
+
73
+ window.copilotRefreshPhases = function() {
74
+ _setPhaseParam(null);
75
+ view_post(_phasesVn, 'phases_html', {}, (r) => {
76
+ if (r && r.html) document.getElementById('phases-panel').innerHTML = r.html;
77
+ });
78
+ };
79
+
80
+ function _refreshPhaseArea(idx, taskType) {
81
+ const areaId = taskType === 'plugin' ? 'phase-plugins-area'
82
+ : taskType === 'data_model' ? 'phase-data-model-area'
83
+ : 'phase-features-area';
84
+ view_post(_phasesVn, 'phase_tasks_html', { idx, task_type: taskType }, (r) => {
85
+ const el = document.getElementById(areaId);
86
+ if (r && r.html && el) el.innerHTML = r.html;
87
+ });
88
+ }
89
+
90
+ function _refreshAllAreas(idx) {
91
+ _refreshPhaseArea(idx, 'plugin');
92
+ _refreshPhaseArea(idx, 'data_model');
93
+ _refreshPhaseArea(idx, 'feature');
94
+ }
95
+
96
+ window.copilotRefreshPhaseProgress = function(idx) {
97
+ const el = document.getElementById('phase-progress-area-' + idx);
98
+ if (!el) return;
99
+ view_post(_phasesVn, 'phase_progress_html', { idx }, (r) => {
100
+ if (r && r.html) el.innerHTML = r.html;
101
+ });
102
+ };
103
+
104
+ window.openPhaseDetail = function(idx) {
105
+ _setPhaseParam(idx);
106
+ view_post(_phasesVn, 'phase_detail_html', { idx }, (r) => {
107
+ if (r && r.html) {
108
+ const panel = document.getElementById('phases-panel');
109
+ panel.innerHTML = r.html;
110
+ panel.dataset.phaseIdx = idx;
111
+
112
+ for (const tt of ['plugin', 'data_model', 'feature']) {
113
+ view_post(_phasesVn, 'phase_tasks_status', { idx, task_type: tt }, (resp) => {
114
+ if (resp && resp.generating && !window.dynamic_updates_cfg?.enabled) phaseTasksPoll(idx, tt);
115
+ });
116
+ if (!window.dynamic_updates_cfg?.enabled) {
117
+ view_post(_phasesVn, 'phase_run_status', { idx, task_type: tt }, (resp) => {
118
+ if (resp && (resp.isRunning || resp.anyRunning)) phasePollRunning(idx, tt);
119
+ });
120
+ }
121
+ }
122
+ }
123
+ });
124
+ };
125
+
126
+ window.startPhaseTasks = function(btn, idx, taskType) {
127
+ btn.disabled = true;
128
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Running…';
129
+ view_post(_phasesVn, 'run_phase_tasks', { idx, task_type: taskType }, () => {});
130
+ if (!window.dynamic_updates_cfg?.enabled) phasePollRunning(idx, taskType);
131
+ };
132
+
133
+ window.stopPhaseTasks = function(idx, taskType) {
134
+ view_post(_phasesVn, 'stop_phase_tasks', { idx, task_type: taskType }, (resp) => {
135
+ if (resp && resp.taskStillRunning) {
136
+ const statusEl = document.getElementById('phase-run-status-' + idx + '-' + taskType);
137
+ if (statusEl) statusEl.textContent = 'Stopping after current task…';
138
+ if (!window.dynamic_updates_cfg?.enabled) phasePollRunning(idx, taskType);
139
+ } else {
140
+ _refreshPhaseArea(idx, taskType);
141
+ }
142
+ });
143
+ };
144
+
145
+ function phasePollRunning(idx, taskType) {
146
+ let lastTaskName;
147
+ const poll = () => {
148
+ view_post(_phasesVn, 'phase_run_status', { idx, task_type: taskType }, (resp) => {
149
+ if (!resp) return;
150
+ if (resp.isRunning || resp.anyRunning) {
151
+ if (resp.runningTaskName !== lastTaskName) {
152
+ lastTaskName = resp.runningTaskName;
153
+ _refreshPhaseArea(idx, taskType);
154
+ }
155
+ setTimeout(poll, 3000);
156
+ } else {
157
+ _refreshPhaseArea(idx, taskType);
158
+ if (!window.dynamic_updates_cfg?.enabled && typeof copilotRefreshSchema === 'function') copilotRefreshSchema();
159
+ }
160
+ });
161
+ };
162
+ setTimeout(poll, 2000);
163
+ }
164
+
165
+ window.runPhaseTask = function(btn, id, phaseIdx, taskType, force) {
166
+ view_post(_phasesVn, 'run_task', { id, force: !!force }, (resp) => {
167
+ if (resp && resp.unmet_deps && resp.unmet_deps.length > 0) {
168
+ const msg = 'These dependencies are not yet done:\\n\\n ' + resp.unmet_deps.join('\\n ') + '\\n\\nRun this task anyway?';
169
+ if (!confirm(msg)) return;
170
+ window.runPhaseTask(btn, id, phaseIdx, taskType, true);
171
+ return;
172
+ }
173
+ btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
174
+ btn.disabled = true;
175
+ const poll = () => {
176
+ view_post(_phasesVn, 'task_status', { ids: [String(id)] }, (statusResp) => {
177
+ if (statusResp && statusResp.any_done) {
178
+ _refreshPhaseArea(phaseIdx, taskType);
179
+ if (typeof copilotRefreshSchema === 'function') copilotRefreshSchema();
180
+ } else setTimeout(poll, 3000);
181
+ });
182
+ };
183
+ if (!window.dynamic_updates_cfg?.enabled) setTimeout(poll, 3000);
184
+ });
185
+ };
186
+
187
+ window.delPhaseTask = function(id, phaseIdx, taskType) {
188
+ if (!confirm('Delete this task?')) return;
189
+ view_post(_phasesVn, 'del_task', { id }, () => {
190
+ _refreshPhaseArea(phaseIdx, taskType);
191
+ });
192
+ };
193
+
194
+ window.resetPhaseTask = function(id, phaseIdx, taskType) {
195
+ if (!confirm('Reset this task to To do?')) return;
196
+ view_post(_phasesVn, 'reset_task', { id }, () => {
197
+ _refreshPhaseArea(phaseIdx, taskType);
198
+ });
199
+ };
200
+
201
+ window.delAllPhaseTasks = function(idx, taskType) {
202
+ if (!confirm('Delete all tasks in this tab?')) return;
203
+ view_post(_phasesVn, 'del_phase_type_tasks', { idx, task_type: taskType }, () => {
204
+ _refreshPhaseArea(idx, taskType);
205
+ });
206
+ };
207
+
208
+ function phaseTasksPoll(idx, taskType) {
209
+ const poll = () => {
210
+ view_post(_phasesVn, 'phase_tasks_status', { idx, task_type: taskType }, (resp) => {
211
+ if (resp && !resp.generating) {
212
+ _refreshPhaseArea(idx, taskType);
213
+ } else setTimeout(poll, 3000);
214
+ });
215
+ };
216
+ setTimeout(poll, 3000);
217
+ }
218
+
219
+ window.generatePhaseTasks = function(idx, taskType) {
220
+ const areaId = taskType === 'plugin' ? 'phase-plugins-area'
221
+ : taskType === 'data_model' ? 'phase-data-model-area'
222
+ : 'phase-features-area';
223
+ const el = document.getElementById(areaId);
224
+ const hasTasks = el && el.querySelector('[data-task-id]');
225
+ if (hasTasks && !confirm('Regenerate tasks? This will delete all existing tasks in this tab.')) return;
226
+ if (el) el.innerHTML = '<p><i class="fas fa-spinner fa-spin me-2"></i>Generating tasks, please wait...</p>';
227
+ view_post(_phasesVn, 'generate_phase_tasks', { idx, task_type: taskType }, () => {});
228
+ if (!window.dynamic_updates_cfg?.enabled) phaseTasksPoll(idx, taskType);
229
+ };
230
+
231
+ window.copilotPhaseTasksDone = function(idx) {
232
+ _refreshAllAreas(idx);
233
+ };
234
+
235
+ window.copilotRefreshTasks = function() {
236
+ const panel = document.getElementById('phases-panel');
237
+ if (!panel || panel.dataset.phaseIdx === undefined) return;
238
+ const idx = parseInt(panel.dataset.phaseIdx);
239
+ if (!isNaN(idx)) _refreshAllAreas(idx);
240
+ };
241
+
242
+ function copilotInitPhasesState() {
243
+ if (document.getElementById('phases-generating-state')) {
244
+ if (!window.dynamic_updates_cfg?.enabled) phasesStartPoll();
245
+ }
246
+ const phaseParam = new URLSearchParams(location.search).get('phase');
247
+ if (phaseParam !== null) openPhaseDetail(parseInt(phaseParam));
248
+ }
249
+ (function() {
250
+ if (document.readyState !== 'loading') copilotInitPhasesState();
251
+ else document.addEventListener('DOMContentLoaded', copilotInitPhasesState);
252
+ })();
253
+ </script>`;
254
+
255
+ // ── Shared helpers ────────────────────────────────────────────────────────────
256
+
257
+ const phases_tool = {
258
+ type: "function",
259
+ function: {
260
+ name: "set_phases",
261
+ description:
262
+ "Set the development phases for the application. Each phase groups a set of requirements that belong together and should be built in the same iteration.",
263
+ parameters: {
264
+ type: "object",
265
+ required: ["phases"],
266
+ additionalProperties: false,
267
+ properties: {
268
+ phases: {
269
+ type: "array",
270
+ minItems: 1,
271
+ description: "Ordered list of development phases",
272
+ items: {
273
+ type: "object",
274
+ required: ["name", "description", "requirements"],
275
+ additionalProperties: false,
276
+ properties: {
277
+ name: {
278
+ type: "string",
279
+ description:
280
+ "Short phase name, e.g. 'Phase 1: Core data entry'",
281
+ },
282
+ description: {
283
+ type: "string",
284
+ description:
285
+ "1–3 sentences describing what this phase delivers and why it forms a coherent milestone",
286
+ },
287
+ requirements: {
288
+ type: "array",
289
+ description:
290
+ "The requirements that belong to this phase, in the same format as make_requirements",
291
+ items: {
292
+ type: "object",
293
+ required: ["requirement", "priority"],
294
+ additionalProperties: false,
295
+ properties: {
296
+ requirement: {
297
+ type: "string",
298
+ description: "A statement of the requirement",
299
+ },
300
+ priority: {
301
+ type: "number",
302
+ description:
303
+ "Priority 1-5. 5: Must-have for this phase, 1: Nice-to-have",
304
+ },
305
+ },
306
+ },
307
+ },
308
+ },
309
+ },
310
+ },
311
+ },
312
+ },
313
+ },
314
+ };
315
+
316
+ const phasesSpinner =
317
+ `<p id="phases-generating-state">` +
318
+ i({ class: "fas fa-spinner fa-spin me-2" }) +
319
+ "Generating phases, please wait...</p>";
320
+
321
+ const tasksSpinner =
322
+ "<p>" +
323
+ i({ class: "fas fa-spinner fa-spin me-2" }) +
324
+ "Generating tasks, please wait...</p>";
325
+
326
+ // ── Phase list view ───────────────────────────────────────────────────────────
327
+
328
+ const phaseCard = (phase, idx, req, allDone, hasFeedback) => {
329
+ const starFieldview = getState().types.Integer.fieldviews.show_star_rating;
330
+ const reqs = phase.requirements || [];
331
+ const reqList = reqs.length
332
+ ? ul(
333
+ { class: "list-unstyled mb-0 mt-2" },
334
+ ...reqs.map((r) =>
335
+ li(
336
+ { class: "d-flex align-items-start gap-2 mb-2" },
337
+ span(
338
+ { class: "flex-shrink-0" },
339
+ starFieldview.run(r.priority, req, { min: 1, max: 5 })
340
+ ),
341
+ span({ class: "text-muted small" }, r.requirement)
342
+ )
343
+ )
344
+ )
345
+ : "";
346
+
347
+ const numberBadge = allDone
348
+ ? span(
349
+ {
350
+ class:
351
+ "badge bg-success rounded-circle d-flex align-items-center justify-content-center fs-6 flex-shrink-0",
352
+ style: "min-width:2rem;height:2rem;",
353
+ title: "All tasks done",
354
+ },
355
+ i({ class: "fas fa-check" })
356
+ )
357
+ : span(
358
+ {
359
+ class:
360
+ "badge bg-primary rounded-circle d-flex align-items-center justify-content-center fs-6 flex-shrink-0",
361
+ style: "min-width:2rem;height:2rem;",
362
+ },
363
+ String(idx + 1)
364
+ );
365
+
366
+ return div(
367
+ {
368
+ class: `card mb-3 shadow-sm border-start border-4 ${
369
+ allDone ? "border-success" : "border-primary"
370
+ }`,
371
+ },
372
+ div(
373
+ { class: "card-body" },
374
+ div(
375
+ { class: "d-flex align-items-start gap-3 mb-2" },
376
+ numberBadge,
377
+ div(
378
+ { class: "flex-grow-1" },
379
+ h6({ class: "card-title fw-semibold mb-1" }, phase.name),
380
+ p({ class: "card-text text-muted mb-0 small" }, phase.description)
381
+ ),
382
+ div(
383
+ {
384
+ class:
385
+ "d-flex align-items-center gap-2 flex-shrink-0 align-self-start",
386
+ },
387
+ hasFeedback
388
+ ? i({
389
+ class: "fas fa-comment-alt text-warning",
390
+ title: "This phase has feedback",
391
+ style: "font-size:0.95rem;",
392
+ })
393
+ : "",
394
+ button(
395
+ {
396
+ class: "btn btn-outline-primary btn-sm",
397
+ onclick: `openPhaseDetail(${idx})`,
398
+ title: "Open phase",
399
+ },
400
+ i({ class: "fas fa-arrow-right" })
401
+ )
402
+ )
403
+ ),
404
+ reqs.length
405
+ ? div(
406
+ { class: "border-top pt-2 mt-1" },
407
+ small(
408
+ {
409
+ class: "text-muted text-uppercase fw-semibold d-block mb-2",
410
+ style: "font-size:0.7rem;letter-spacing:.05em;",
411
+ },
412
+ `${reqs.length} requirement${reqs.length !== 1 ? "s" : ""}`
413
+ ),
414
+ reqList
415
+ )
416
+ : ""
417
+ )
418
+ );
419
+ };
420
+
421
+ const phasesHtml = async (req) => {
422
+ const generating = await MetaData.findOne({
423
+ type: "CopilotConstructMgr",
424
+ name: "generating_phases",
425
+ });
426
+ if (generating) return phasesSpinner;
427
+
428
+ const phasesMd = await MetaData.findOne({
429
+ type: "CopilotConstructMgr",
430
+ name: "phases",
431
+ });
432
+
433
+ const generateBtn = (label) =>
434
+ button(
435
+ { class: "btn btn-primary btn-sm", onclick: "copilotGenPhases()" },
436
+ i({ class: "fas fa-magic me-1" }),
437
+ label
438
+ );
439
+
440
+ if (!phasesMd || !phasesMd.body?.phases?.length) {
441
+ return (
442
+ p(
443
+ { class: "text-muted" },
444
+ "No phases yet. Generate them from your specification and research answers."
445
+ ) + generateBtn("Generate phases")
446
+ );
447
+ }
448
+
449
+ const phases = phasesMd.body.phases;
450
+ const totalReqs = phases.reduce(
451
+ (s, ph) => s + (ph.requirements?.length || 0),
452
+ 0
453
+ );
454
+
455
+ const allTasks = await MetaData.find({
456
+ type: "CopilotConstructMgr",
457
+ name: "task",
458
+ });
459
+ const pluginMarkers = await MetaData.find({
460
+ type: "CopilotConstructMgr",
461
+ name: "phase_plugin_generated",
462
+ });
463
+ const pluginMarkerIdxs = new Set(pluginMarkers.map((m) => m.body?.phase_idx));
464
+
465
+ const phaseAllDone = phases.map((_, idx) => {
466
+ const phaseTasks = allTasks.filter((t) => t.body?.phase_idx === idx);
467
+ const byType = (type) =>
468
+ phaseTasks.filter((t) => (t.body.task_type || "feature") === type);
469
+ const dmTasks = byType("data_model");
470
+ const ftTasks = byType("feature");
471
+ const plTasks = byType("plugin");
472
+ const allDone = (tasks) =>
473
+ tasks.length > 0 && tasks.every((t) => t.body?.status === "Done");
474
+ // plugin: ok if marker says 0 were needed, or if tasks exist and all done
475
+ const pluginOk = pluginMarkerIdxs.has(idx) || allDone(plTasks);
476
+ return allDone(dmTasks) && allDone(ftTasks) && pluginOk;
477
+ });
478
+
479
+ const phasesWithFeedback = new Set();
480
+ try {
481
+ const fbMds = await MetaData.find({
482
+ type: "CopilotConstructMgr",
483
+ name: "feedback_pending",
484
+ });
485
+ for (const r of fbMds)
486
+ if (r.body?.phase_idx != null) phasesWithFeedback.add(r.body.phase_idx);
487
+ } catch (_) {}
488
+
489
+ return (
490
+ div(
491
+ { class: "d-flex justify-content-between align-items-center mb-3" },
492
+ small(
493
+ { class: "text-muted" },
494
+ `${phases.length} phase${
495
+ phases.length !== 1 ? "s" : ""
496
+ }, ${totalReqs} requirement${totalReqs !== 1 ? "s" : ""}`
497
+ ),
498
+ generateBtn("Regenerate")
499
+ ) +
500
+ phases
501
+ .map((ph, idx) =>
502
+ phaseCard(ph, idx, req, phaseAllDone[idx], phasesWithFeedback.has(idx))
503
+ )
504
+ .join("") +
505
+ div(
506
+ { class: "mt-3" },
507
+ button(
508
+ {
509
+ class: "btn btn-outline-danger btn-sm",
510
+ onclick: "copilotDelAllPhases()",
511
+ },
512
+ i({ class: "fas fa-trash me-1" }),
513
+ "Delete all phases & tasks"
514
+ )
515
+ )
516
+ );
517
+ };
518
+
519
+ // ── Phase detail: tasks tab ───────────────────────────────────────────────────
520
+
521
+ const taskStatusBadge = (status) => {
522
+ const cls =
523
+ status === "Done"
524
+ ? "bg-success"
525
+ : status === "Running"
526
+ ? "bg-warning text-dark"
527
+ : "bg-secondary";
528
+ return span({ class: `badge ${cls}` }, status || "To do");
529
+ };
530
+
531
+ const phaseTasksHtml = async (phaseIdx, taskType) => {
532
+ const generating = await MetaData.findOne({
533
+ type: "CopilotConstructMgr",
534
+ name: "generating_phase_tasks",
535
+ });
536
+ const isGenerating = !!(
537
+ generating &&
538
+ generating.body?.phase_idx === phaseIdx &&
539
+ (!generating.body?.task_type || generating.body?.task_type === taskType)
540
+ );
541
+ if (isGenerating) return tasksSpinner;
542
+
543
+ const allTasks = await MetaData.find({
544
+ type: "CopilotConstructMgr",
545
+ name: "task",
546
+ });
547
+ const phaseTasks = allTasks.filter((t) => t.body?.phase_idx === phaseIdx);
548
+ const tasks = phaseTasks.filter(
549
+ (t) => (t.body.task_type || "feature") === taskType
550
+ );
551
+ const doneNames = new Set(
552
+ phaseTasks.filter((t) => t.body.status === "Done").map((t) => t.body.name)
553
+ );
554
+
555
+ const phaseRunning = await MetaData.findOne({
556
+ type: "CopilotConstructMgr",
557
+ name: `phase_running_${phaseIdx}_${taskType}`,
558
+ });
559
+ const isRunning = !!phaseRunning;
560
+ const runningTask = tasks.find((t) => t.body.status === "Running");
561
+ const isStopping = !isRunning && !!runningTask;
562
+ const hasTodo = tasks.some(
563
+ (t) => !t.body.status || t.body.status === "To do"
564
+ );
565
+
566
+ const allDone = tasks.length > 0 && !hasTodo && !isRunning && !runningTask;
567
+
568
+ const genLabel = tasks.length ? "Regenerate" : "Generate tasks";
569
+ const genOnclick = tasks.length
570
+ ? `if(confirm('Regenerate tasks? This will replace the existing tasks.')) generatePhaseTasks(${phaseIdx},'${taskType}')`
571
+ : `generatePhaseTasks(${phaseIdx},'${taskType}')`;
572
+ const genBtn = button(
573
+ {
574
+ class: `btn btn-primary btn-sm${tasks.length ? " ms-auto" : ""}`,
575
+ onclick: genOnclick,
576
+ },
577
+ i({ class: "fas fa-magic me-1" }),
578
+ genLabel
579
+ );
580
+
581
+ const delAllBtn = tasks.length
582
+ ? button(
583
+ {
584
+ class: "btn btn-outline-danger btn-sm",
585
+ onclick: `delAllPhaseTasks(${phaseIdx},'${taskType}')`,
586
+ title: "Delete all tasks",
587
+ },
588
+ i({ class: "fas fa-trash me-1" }),
589
+ "Delete all"
590
+ )
591
+ : "";
592
+
593
+ const runBtn = isRunning
594
+ ? button(
595
+ { class: "btn btn-success btn-sm", disabled: true },
596
+ i({ class: "fas fa-spinner fa-spin me-1" }),
597
+ "Running…"
598
+ )
599
+ : isStopping
600
+ ? button(
601
+ { class: "btn btn-warning btn-sm", disabled: true },
602
+ i({ class: "fas fa-spinner fa-spin me-1" }),
603
+ "Stopping…"
604
+ )
605
+ : hasTodo
606
+ ? button(
607
+ {
608
+ class: "btn btn-success btn-sm",
609
+ onclick: `startPhaseTasks(this,${phaseIdx},'${taskType}')`,
610
+ },
611
+ i({ class: "fas fa-play me-1" }),
612
+ "Start running"
613
+ )
614
+ : tasks.length
615
+ ? button(
616
+ { class: "btn btn-success btn-sm", disabled: true },
617
+ i({ class: "fas fa-play me-1" }),
618
+ "Start running"
619
+ )
620
+ : "";
621
+
622
+ const stopBtn = isRunning
623
+ ? button(
624
+ {
625
+ class: "btn btn-danger btn-sm",
626
+ onclick: `stopPhaseTasks(${phaseIdx},'${taskType}')`,
627
+ },
628
+ i({ class: "fas fa-stop me-1" }),
629
+ "Stop"
630
+ )
631
+ : "";
632
+
633
+ const statusBar = div(
634
+ { class: "d-flex align-items-center gap-2 mb-3 flex-wrap" },
635
+ span(
636
+ {
637
+ id: `phase-run-status-${phaseIdx}-${taskType}`,
638
+ class: "small text-muted me-1",
639
+ },
640
+ isStopping
641
+ ? "Stopping after current task…"
642
+ : runningTask
643
+ ? span(
644
+ "Running: ",
645
+ span({ class: "fw-bold text-body" }, runningTask.body.name)
646
+ )
647
+ : tasks.length
648
+ ? "Not running"
649
+ : ""
650
+ ),
651
+ runBtn,
652
+ stopBtn,
653
+ genBtn
654
+ );
655
+
656
+ const renderTask = (t) => {
657
+ const status = t.body.status || "To do";
658
+ const isRunning = status === "Running";
659
+ const isDone = status === "Done";
660
+ const isTodo = !isDone && !isRunning;
661
+
662
+ const deps = t.body.depends_on || [];
663
+ const depsHtml = deps.length
664
+ ? div(
665
+ { class: "d-flex flex-wrap gap-1 mt-2" },
666
+ ...deps.map((dep) =>
667
+ span(
668
+ {
669
+ class: "d-inline-flex align-items-center gap-1 text-muted",
670
+ style: `font-size:0.7rem;background:${
671
+ doneNames.has(dep)
672
+ ? "rgba(25,135,84,.18)"
673
+ : "rgba(220,53,69,.18)"
674
+ };border-radius:4px;padding:1px 5px;`,
675
+ },
676
+ i({
677
+ class: `fas ${
678
+ doneNames.has(dep)
679
+ ? "fa-check-circle text-success"
680
+ : "fa-circle text-danger"
681
+ } `,
682
+ style: "font-size:0.6rem;opacity:0.9",
683
+ }),
684
+ dep
685
+ )
686
+ )
687
+ )
688
+ : "";
689
+
690
+ const taskRunBtn = isRunning
691
+ ? span(
692
+ { class: "task-spinner", "data-task-id": t.id },
693
+ i({ class: "fas fa-spinner fa-spin text-warning" })
694
+ )
695
+ : isDone
696
+ ? t.body.run_id
697
+ ? a(
698
+ {
699
+ target: "_blank",
700
+ href: `/view/Saltcorn%20Agent%20copilot?run_id=${t.body.run_id}`,
701
+ class: "btn btn-outline-secondary btn-sm",
702
+ title: "View run",
703
+ },
704
+ i({ class: "fas fa-external-link-alt" })
705
+ )
706
+ : ""
707
+ : button(
708
+ {
709
+ class: "btn btn-outline-success btn-sm",
710
+ "data-task-run": t.id,
711
+ onclick: `runPhaseTask(this,${t.id},${phaseIdx},'${taskType}')`,
712
+ title: "Run task",
713
+ },
714
+ i({ class: "fas fa-play" })
715
+ );
716
+
717
+ const editBtn =
718
+ isTodo && features.view_route_modal
719
+ ? button(
720
+ {
721
+ class: "btn btn-outline-primary btn-sm",
722
+ title: "Edit description",
723
+ onclick: `ajax_modal('/view/${encodeURIComponent(
724
+ viewname
725
+ )}/edit_task_desc?id=${t.id}', {method:'POST'})`,
726
+ },
727
+ i({ class: "fas fa-edit" })
728
+ )
729
+ : "";
730
+
731
+ const resetBtn =
732
+ isRunning || isDone
733
+ ? button(
734
+ {
735
+ class: "btn btn-outline-secondary btn-sm",
736
+ onclick: `resetPhaseTask(${t.id},${phaseIdx},'${taskType}')`,
737
+ title: "Reset to To do",
738
+ },
739
+ i({ class: "fas fa-undo" })
740
+ )
741
+ : "";
742
+
743
+ const deleteBtn = button(
744
+ {
745
+ class: "btn btn-outline-danger btn-sm",
746
+ onclick: `delPhaseTask(${t.id},${phaseIdx},'${taskType}')`,
747
+ title: "Delete",
748
+ },
749
+ i({ class: "fas fa-trash-alt" })
750
+ );
751
+
752
+ return div(
753
+ { class: "card mb-2 shadow-sm" },
754
+ div(
755
+ { class: "card-body py-2" },
756
+ div(
757
+ { class: "d-flex align-items-start gap-2" },
758
+ div(
759
+ { class: "flex-grow-1" },
760
+ div(
761
+ { class: "d-flex align-items-center flex-wrap gap-1 mb-1" },
762
+ span({ class: "fw-semibold small me-1" }, t.body.name),
763
+ taskStatusBadge(status),
764
+ span(
765
+ { class: "badge bg-secondary", title: "Priority" },
766
+ String(t.body.priority ?? "?")
767
+ )
768
+ ),
769
+ p({ class: "text-muted small mb-0" }, t.body.description),
770
+ depsHtml
771
+ ),
772
+ div(
773
+ { class: "d-flex gap-1 flex-shrink-0 ms-2" },
774
+ taskRunBtn,
775
+ editBtn,
776
+ resetBtn,
777
+ deleteBtn
778
+ )
779
+ )
780
+ )
781
+ );
782
+ };
783
+
784
+ const plannedTasks = tasks.filter((t) => t.body.source !== "feedback");
785
+ const feedbackTasks = tasks.filter((t) => t.body.source === "feedback");
786
+
787
+ if (!tasks.length) {
788
+ let emptyMsg = "No tasks yet.";
789
+ if (taskType === "plugin") {
790
+ const markers = await MetaData.find({
791
+ type: "CopilotConstructMgr",
792
+ name: "phase_plugin_generated",
793
+ });
794
+ if (markers.some((m) => m.body?.phase_idx === phaseIdx))
795
+ emptyMsg = "No plugin installations needed for this phase.";
796
+ } else if (taskType === "data_model") {
797
+ const markers = await MetaData.find({
798
+ type: "CopilotConstructMgr",
799
+ name: "phase_data_model_generated",
800
+ });
801
+ if (markers.some((m) => m.body?.phase_idx === phaseIdx))
802
+ emptyMsg = "No schema changes needed for this phase.";
803
+ }
804
+ return statusBar + p({ class: "text-muted small mt-2" }, emptyMsg);
805
+ }
806
+
807
+ const feedbackSection = feedbackTasks.length
808
+ ? div(
809
+ { class: "mt-4" },
810
+ div(
811
+ { class: "d-flex align-items-center gap-2 mb-2" },
812
+ small(
813
+ {
814
+ class: "text-uppercase fw-semibold text-muted",
815
+ style: "font-size:0.7rem;letter-spacing:.05em;",
816
+ },
817
+ i({ class: "fas fa-comment-alt me-1" }),
818
+ "From feedback"
819
+ ),
820
+ div({ class: "flex-grow-1 border-top" })
821
+ ),
822
+ feedbackTasks.map(renderTask).join("")
823
+ )
824
+ : "";
825
+
826
+ return (
827
+ statusBar +
828
+ plannedTasks.map(renderTask).join("") +
829
+ feedbackSection +
830
+ div({ class: "mt-3" }, delAllBtn)
831
+ );
832
+ };
833
+
834
+ // ── Phase detail view ─────────────────────────────────────────────────────────
835
+
836
+ const phaseProgressHtml = async (idx) => {
837
+ const allTasks = await MetaData.find({
838
+ type: "CopilotConstructMgr",
839
+ name: "task",
840
+ });
841
+ const taskNameById = Object.fromEntries(
842
+ allTasks.map((t) => [t.id, t.body.name])
843
+ );
844
+
845
+ const allProgress = await MetaData.find(
846
+ { type: "CopilotConstructMgr", name: "progress" },
847
+ { orderBy: "written_at", orderDesc: true }
848
+ );
849
+ const entries = allProgress.filter((p) => p.body?.phase_idx === idx);
850
+
851
+ if (!entries.length)
852
+ return p({ class: "text-muted mt-2" }, "No completed tasks yet.");
853
+
854
+ return (
855
+ mkTable(
856
+ [
857
+ {
858
+ label: "When",
859
+ key: (m) => {
860
+ const d = m.written_at ? new Date(m.written_at) : null;
861
+ if (!d) return "";
862
+ return small(
863
+ { class: "text-muted", style: "white-space:nowrap" },
864
+ d.toLocaleDateString([], { month: "short", day: "numeric" }),
865
+ " ",
866
+ d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
867
+ );
868
+ },
869
+ },
870
+ {
871
+ label: "Task",
872
+ key: (m) =>
873
+ small(
874
+ { class: "text-muted", style: "white-space:nowrap" },
875
+ taskNameById[m.body?.task_id] || ""
876
+ ),
877
+ },
878
+ {
879
+ label: "Summary",
880
+ key: (m) =>
881
+ div(
882
+ {
883
+ style:
884
+ "white-space:pre-wrap;font-size:0.82rem;max-width:520px;",
885
+ },
886
+ m.body.text || ""
887
+ ),
888
+ },
889
+ ],
890
+ entries
891
+ ) +
892
+ button(
893
+ {
894
+ class: "btn btn-outline-danger btn-sm mt-2",
895
+ onclick: `view_post(${JSON.stringify(
896
+ viewname
897
+ )}, 'del_phase_progress', {idx:${idx}}, () => copilotRefreshPhaseProgress(${idx}))`,
898
+ },
899
+ "Delete all"
900
+ )
901
+ );
902
+ };
903
+
904
+ const phaseDetailHtml = async (phase, idx) => {
905
+ const tabId = `phase-detail-tabs-${idx}`;
906
+ const [plContent, dmContent, ftContent, pgContent] = await Promise.all([
907
+ phaseTasksHtml(idx, "plugin"),
908
+ phaseTasksHtml(idx, "data_model"),
909
+ phaseTasksHtml(idx, "feature"),
910
+ phaseProgressHtml(idx),
911
+ ]);
912
+
913
+ const backBtn = button(
914
+ {
915
+ class: "btn btn-sm btn-outline-secondary mb-3",
916
+ onclick: "copilotRefreshPhases()",
917
+ },
918
+ i({ class: "fas fa-arrow-left me-1" }),
919
+ "Back to phases"
920
+ );
921
+
922
+ const feedbackBtn = features.view_route_modal
923
+ ? button(
924
+ {
925
+ class: "btn btn-outline-secondary btn-sm flex-shrink-0 ms-4",
926
+ onclick: `ajax_modal('/view/${encodeURIComponent(
927
+ viewname
928
+ )}/get_feedback_form?scope=phase_${idx}', {method:'POST'})`,
929
+ title: "Add feedback for this phase",
930
+ },
931
+ i({ class: "fas fa-comment-alt me-1" }),
932
+ "Feedback"
933
+ )
934
+ : "";
935
+
936
+ const header = div(
937
+ { class: "d-flex align-items-start gap-3 mb-2" },
938
+ span(
939
+ {
940
+ class:
941
+ "badge bg-primary rounded-circle d-flex align-items-center justify-content-center fs-6 flex-shrink-0",
942
+ style: "min-width:2rem;height:2rem;",
943
+ },
944
+ String(idx + 1)
945
+ ),
946
+ div(
947
+ { class: "flex-grow-1" },
948
+ h6({ class: "fw-semibold mb-1" }, phase.name),
949
+ p({ class: "text-muted small mb-0" }, phase.description)
950
+ ),
951
+ feedbackBtn
952
+ );
953
+
954
+ const tabs = `
955
+ <ul class="nav nav-tabs" id="${tabId}" role="tablist">
956
+ <li class="nav-item" role="presentation">
957
+ <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#${tabId}-pl" type="button">Plugins</button>
958
+ </li>
959
+ <li class="nav-item" role="presentation">
960
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#${tabId}-dm" type="button">Data model</button>
961
+ </li>
962
+ <li class="nav-item" role="presentation">
963
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#${tabId}-ft" type="button">Features</button>
964
+ </li>
965
+ <li class="nav-item" role="presentation">
966
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#${tabId}-pg" type="button">Progress</button>
967
+ </li>
968
+ </ul>
969
+ <div class="tab-content pt-3">
970
+ <div class="tab-pane fade show active" id="${tabId}-pl">
971
+ <div id="phase-plugins-area">${plContent}</div>
972
+ </div>
973
+ <div class="tab-pane fade" id="${tabId}-dm">
974
+ <div id="phase-data-model-area">${dmContent}</div>
975
+ </div>
976
+ <div class="tab-pane fade" id="${tabId}-ft">
977
+ <div id="phase-features-area">${ftContent}</div>
978
+ </div>
979
+ <div class="tab-pane fade" id="${tabId}-pg">
980
+ <div id="phase-progress-area-${idx}">${pgContent}</div>
981
+ </div>
982
+ </div>`;
983
+
984
+ return backBtn + header + tabs;
985
+ };
986
+
987
+ // ── Panel wrapper (rendered on page load) ─────────────────────────────────────
988
+
989
+ const phasesPanel = async (req) => {
990
+ const innerHtml = await phasesHtml(req);
991
+ return div({ class: "mt-2" }, div({ id: "phases-panel" }, innerHtml));
992
+ };
993
+
994
+ // ── Phase generation ──────────────────────────────────────────────────────────
995
+
996
+ const doGenPhases = async (userId) => {
997
+ const generatingMd = await MetaData.create({
998
+ type: "CopilotConstructMgr",
999
+ name: "generating_phases",
1000
+ body: {},
1001
+ user_id: userId,
1002
+ });
1003
+ try {
1004
+ const generator = await PromptGenerator.createInstance();
1005
+ if (!generator.spec) throw new Error("Specification not found");
1006
+ const answer = await getState().functions.llm_generate.run(
1007
+ generator.phasesPlanPrompt(),
1008
+ {
1009
+ tools: [phases_tool],
1010
+ ...tool_choice("set_phases"),
1011
+ systemPrompt:
1012
+ "You are a senior software architect and project manager. " +
1013
+ "Break the application into logical delivery phases, each containing the requirements that belong to that phase. " +
1014
+ "Only include what is explicitly stated in the specification — do not infer or add plausible extras.",
1015
+ }
1016
+ );
1017
+
1018
+ const tc = answer.getToolCalls()[0];
1019
+
1020
+ // Delete all phase tasks before replacing phases (phase indices will shift)
1021
+ const allTasks = await MetaData.find({
1022
+ type: "CopilotConstructMgr",
1023
+ name: "task",
1024
+ });
1025
+ for (const t of allTasks.filter((t) => t.body?.phase_idx !== undefined))
1026
+ await t.delete();
1027
+
1028
+ const existing = await MetaData.findOne({
1029
+ type: "CopilotConstructMgr",
1030
+ name: "phases",
1031
+ });
1032
+ if (existing) {
1033
+ await existing.update({ body: { phases: tc.input.phases } });
1034
+ } else {
1035
+ await MetaData.create({
1036
+ type: "CopilotConstructMgr",
1037
+ name: "phases",
1038
+ body: { phases: tc.input.phases },
1039
+ user_id: userId,
1040
+ });
1041
+ }
1042
+ } finally {
1043
+ await generatingMd.delete();
1044
+ try {
1045
+ getState().emitDynamicUpdate(db.getTenantSchema(), {
1046
+ eval_js:
1047
+ "if(typeof copilotRefreshPhases==='function')copilotRefreshPhases();",
1048
+ });
1049
+ } catch (_) {}
1050
+ }
1051
+ };
1052
+
1053
+ // ── Task generation for a phase ───────────────────────────────────────────────
1054
+
1055
+ // taskType: "data_model" | "feature" | null (null = generate both)
1056
+ const doGenPhaseTasks = async (phase, userId, taskType) => {
1057
+ const generatingMd = await MetaData.create({
1058
+ type: "CopilotConstructMgr",
1059
+ name: "generating_phase_tasks",
1060
+ body: { phase_idx: phase.idx, task_type: taskType },
1061
+ user_id: userId,
1062
+ });
1063
+ try {
1064
+ const generator = await PromptGenerator.createInstance({ phase });
1065
+ const answer = await getState().functions.llm_generate.run(
1066
+ generator.taskPlanPrompt(taskType),
1067
+ {
1068
+ tools: [task_tool],
1069
+ ...tool_choice("plan_tasks"),
1070
+ systemPrompt:
1071
+ "You are a project manager planning implementation tasks for a Saltcorn application. " +
1072
+ "Each task must map to a concrete deliverable (a view, page, trigger, or schema change). " +
1073
+ "Keep tasks small and focused.",
1074
+ }
1075
+ );
1076
+
1077
+ const tc = answer.getToolCalls()[0];
1078
+
1079
+ // Remove existing tasks of the relevant type(s) before storing new ones
1080
+ const existing = await MetaData.find({
1081
+ type: "CopilotConstructMgr",
1082
+ name: "task",
1083
+ });
1084
+ const phaseTasks = existing.filter((t) => t.body?.phase_idx === phase.idx);
1085
+ for (const t of phaseTasks) {
1086
+ const tType = t.body.task_type || "feature";
1087
+ if (tType === taskType) await t.delete();
1088
+ }
1089
+
1090
+ // Clear any existing "no tasks needed" markers for this phase
1091
+ if (taskType === "plugin") {
1092
+ const oldMarkers = await MetaData.find({
1093
+ type: "CopilotConstructMgr",
1094
+ name: "phase_plugin_generated",
1095
+ });
1096
+ for (const m of oldMarkers.filter((m) => m.body?.phase_idx === phase.idx))
1097
+ await m.delete();
1098
+ } else if (taskType === "data_model") {
1099
+ const oldMarkers = await MetaData.find({
1100
+ type: "CopilotConstructMgr",
1101
+ name: "phase_data_model_generated",
1102
+ });
1103
+ for (const m of oldMarkers.filter((m) => m.body?.phase_idx === phase.idx))
1104
+ await m.delete();
1105
+ }
1106
+
1107
+ for (const task of tc.input.tasks)
1108
+ await MetaData.create({
1109
+ type: "CopilotConstructMgr",
1110
+ name: "task",
1111
+ body: { ...task, phase_idx: phase.idx, phase_name: phase.name },
1112
+ user_id: userId,
1113
+ });
1114
+
1115
+ // If generation produced 0 tasks, record that it was considered
1116
+ if (
1117
+ taskType === "plugin" &&
1118
+ tc.input.tasks.filter((t) => t.task_type === "plugin").length === 0
1119
+ ) {
1120
+ await MetaData.create({
1121
+ type: "CopilotConstructMgr",
1122
+ name: "phase_plugin_generated",
1123
+ body: { phase_idx: phase.idx },
1124
+ user_id: userId,
1125
+ });
1126
+ }
1127
+ if (
1128
+ taskType === "data_model" &&
1129
+ tc.input.tasks.filter((t) => t.task_type === "data_model").length === 0
1130
+ ) {
1131
+ await MetaData.create({
1132
+ type: "CopilotConstructMgr",
1133
+ name: "phase_data_model_generated",
1134
+ body: { phase_idx: phase.idx },
1135
+ user_id: userId,
1136
+ });
1137
+ }
1138
+ } finally {
1139
+ await generatingMd.delete();
1140
+ try {
1141
+ getState().emitDynamicUpdate(db.getTenantSchema(), {
1142
+ eval_js: `if(typeof copilotPhaseTasksDone==='function')copilotPhaseTasksDone(${phase.idx});`,
1143
+ });
1144
+ } catch (_) {}
1145
+ }
1146
+ };
1147
+
1148
+ // ── Phase task chain runner ───────────────────────────────────────────────────
1149
+
1150
+ const doRunPhaseTasks = async (phaseIdx, taskType, req) => {
1151
+ const flagName = `phase_running_${phaseIdx}_${taskType}`;
1152
+ const running = await MetaData.findOne({
1153
+ type: "CopilotConstructMgr",
1154
+ name: flagName,
1155
+ });
1156
+ if (!running) return;
1157
+
1158
+ const allTasks = await MetaData.find({
1159
+ type: "CopilotConstructMgr",
1160
+ name: "task",
1161
+ });
1162
+ const phaseTasks = allTasks.filter((t) => t.body?.phase_idx === phaseIdx);
1163
+ const typedTasks = phaseTasks.filter(
1164
+ (t) => (t.body.task_type || "feature") === taskType
1165
+ );
1166
+
1167
+ if (typedTasks.some((t) => t.body.status === "Running")) return;
1168
+
1169
+ const doneNames = new Set(
1170
+ phaseTasks.filter((t) => t.body.status === "Done").map((t) => t.body.name)
1171
+ );
1172
+ // Only block on same-type dependencies; cross-type deps are the user's ordering concern
1173
+ const sameTypeNames = new Set(
1174
+ typedTasks.map((t) => t.body.name).filter(Boolean)
1175
+ );
1176
+ const todos = typedTasks.filter(
1177
+ (t) => !t.body.status || t.body.status === "To do"
1178
+ );
1179
+ const startable = todos.filter((t) =>
1180
+ (t.body.depends_on || []).every(
1181
+ (nm) => doneNames.has(nm) || !sameTypeNames.has(nm)
1182
+ )
1183
+ );
1184
+
1185
+ if (startable[0]) {
1186
+ await runTask(startable[0].id, req);
1187
+ await doRunPhaseTasks(phaseIdx, taskType, req);
1188
+ } else {
1189
+ const runningMd = await MetaData.findOne({
1190
+ type: "CopilotConstructMgr",
1191
+ name: flagName,
1192
+ });
1193
+ if (runningMd) await runningMd.delete();
1194
+ try {
1195
+ getState().emitDynamicUpdate(db.getTenantSchema(), {
1196
+ eval_js: `if(typeof copilotPhaseTasksDone==='function')copilotPhaseTasksDone(${phaseIdx});`,
1197
+ });
1198
+ } catch (_) {}
1199
+ }
1200
+ };
1201
+
1202
+ // ── Routes ────────────────────────────────────────────────────────────────────
1203
+
1204
+ const gen_phases = async (table_id, vn, config, body, { req, res }) => {
1205
+ doGenPhases(req.user?.id).catch((e) => console.error("gen_phases error", e));
1206
+ return { json: { success: true } };
1207
+ };
1208
+
1209
+ const phases_status = async (table_id, vn, config, body, { req, res }) => {
1210
+ const generating = await MetaData.findOne({
1211
+ type: "CopilotConstructMgr",
1212
+ name: "generating_phases",
1213
+ });
1214
+ return { json: { generating: !!generating } };
1215
+ };
1216
+
1217
+ const phases_html = async (table_id, vn, config, body, { req, res }) => {
1218
+ const html = await phasesHtml(req);
1219
+ return { json: { html } };
1220
+ };
1221
+
1222
+ const phase_detail_html = async (table_id, vn, config, body, { req, res }) => {
1223
+ const idx = parseInt(body.idx);
1224
+ const phasesMd = await MetaData.findOne({
1225
+ type: "CopilotConstructMgr",
1226
+ name: "phases",
1227
+ });
1228
+ const phase = phasesMd?.body?.phases?.[idx];
1229
+ if (!phase) return { json: { error: "Phase not found" } };
1230
+ return { json: { html: await phaseDetailHtml(phase, idx) } };
1231
+ };
1232
+
1233
+ const generate_phase_tasks = async (
1234
+ table_id,
1235
+ vn,
1236
+ config,
1237
+ body,
1238
+ { req, res }
1239
+ ) => {
1240
+ const idx = parseInt(body.idx);
1241
+ const taskType = body.task_type || null;
1242
+ const phasesMd = await MetaData.findOne({
1243
+ type: "CopilotConstructMgr",
1244
+ name: "phases",
1245
+ });
1246
+ const phase = phasesMd?.body?.phases?.[idx];
1247
+ if (!phase) return { json: { error: "Phase not found" } };
1248
+ phase.idx = idx;
1249
+ doGenPhaseTasks(phase, req.user?.id, taskType).catch((e) =>
1250
+ console.error("generate_phase_tasks error", e)
1251
+ );
1252
+ return { json: { success: true } };
1253
+ };
1254
+
1255
+ const phase_tasks_status = async (table_id, vn, config, body, { req, res }) => {
1256
+ const idx = parseInt(body.idx);
1257
+ const taskType = body.task_type || null;
1258
+ const generating = await MetaData.findOne({
1259
+ type: "CopilotConstructMgr",
1260
+ name: "generating_phase_tasks",
1261
+ });
1262
+ const isGenerating = !!(
1263
+ generating &&
1264
+ generating.body?.phase_idx === idx &&
1265
+ (!taskType ||
1266
+ !generating.body?.task_type ||
1267
+ generating.body?.task_type === taskType)
1268
+ );
1269
+ return { json: { generating: isGenerating } };
1270
+ };
1271
+
1272
+ const phase_tasks_html = async (table_id, vn, config, body, { req, res }) => {
1273
+ const idx = parseInt(body.idx);
1274
+ const taskType = body.task_type || "feature";
1275
+ const html = await phaseTasksHtml(idx, taskType);
1276
+ return { json: { html } };
1277
+ };
1278
+
1279
+ const run_phase_tasks = async (table_id, vn, config, body, { req, res }) => {
1280
+ const idx = parseInt(body.idx);
1281
+ const taskType = body.task_type || "feature";
1282
+ const flagName = `phase_running_${idx}_${taskType}`;
1283
+ const existing = await MetaData.findOne({
1284
+ type: "CopilotConstructMgr",
1285
+ name: flagName,
1286
+ });
1287
+ if (!existing)
1288
+ await MetaData.create({
1289
+ type: "CopilotConstructMgr",
1290
+ name: flagName,
1291
+ body: { started: Date.now() },
1292
+ user_id: req.user?.id,
1293
+ });
1294
+ doRunPhaseTasks(idx, taskType, {
1295
+ user: req.user,
1296
+ __: req.__ || ((s) => s),
1297
+ getLocale: req.getLocale || (() => "en"),
1298
+ }).catch((e) => console.error("run_phase_tasks error", e));
1299
+ return { json: { success: true } };
1300
+ };
1301
+
1302
+ const stop_phase_tasks = async (table_id, vn, config, body, { req, res }) => {
1303
+ const idx = parseInt(body.idx);
1304
+ const taskType = body.task_type || "feature";
1305
+ const running = await MetaData.findOne({
1306
+ type: "CopilotConstructMgr",
1307
+ name: `phase_running_${idx}_${taskType}`,
1308
+ });
1309
+ if (running) await running.delete();
1310
+ const allTasks = await MetaData.find({
1311
+ type: "CopilotConstructMgr",
1312
+ name: "task",
1313
+ });
1314
+ const taskStillRunning = allTasks.some(
1315
+ (t) =>
1316
+ t.body?.phase_idx === idx &&
1317
+ (t.body?.task_type || "feature") === taskType &&
1318
+ t.body?.status === "Running"
1319
+ );
1320
+ return { json: { success: true, taskStillRunning } };
1321
+ };
1322
+
1323
+ const del_phase_type_tasks = async (
1324
+ table_id,
1325
+ vn,
1326
+ config,
1327
+ body,
1328
+ { req, res }
1329
+ ) => {
1330
+ const idx = parseInt(body.idx);
1331
+ const taskType = body.task_type || "feature";
1332
+ const allTasks = await MetaData.find({
1333
+ type: "CopilotConstructMgr",
1334
+ name: "task",
1335
+ });
1336
+ for (const t of allTasks) {
1337
+ if (
1338
+ t.body?.phase_idx === idx &&
1339
+ (t.body?.task_type || "feature") === taskType
1340
+ )
1341
+ await t.delete();
1342
+ }
1343
+ if (taskType === "plugin") {
1344
+ const markers = await MetaData.find({
1345
+ type: "CopilotConstructMgr",
1346
+ name: "phase_plugin_generated",
1347
+ });
1348
+ for (const m of markers.filter((m) => m.body?.phase_idx === idx))
1349
+ await m.delete();
1350
+ const pluginPhase = await MetaData.find({
1351
+ type: "CopilotConstructMgr",
1352
+ name: "plugin_phase",
1353
+ });
1354
+ for (const m of pluginPhase.filter((m) => m.body?.phase_idx === idx))
1355
+ await m.delete();
1356
+ }
1357
+ if (taskType === "data_model") {
1358
+ const tablePhase = await MetaData.find({
1359
+ type: "CopilotConstructMgr",
1360
+ name: "table_phase",
1361
+ });
1362
+ for (const m of tablePhase.filter((m) => m.body?.phase_idx === idx))
1363
+ await m.delete();
1364
+ const dmMarkers = await MetaData.find({
1365
+ type: "CopilotConstructMgr",
1366
+ name: "phase_data_model_generated",
1367
+ });
1368
+ for (const m of dmMarkers.filter((m) => m.body?.phase_idx === idx))
1369
+ await m.delete();
1370
+ }
1371
+ if (taskType === "feature") {
1372
+ const viewPhase = await MetaData.find({
1373
+ type: "CopilotConstructMgr",
1374
+ name: "view_phase",
1375
+ });
1376
+ for (const m of viewPhase.filter((m) => m.body?.phase_idx === idx))
1377
+ await m.delete();
1378
+ }
1379
+ return { json: { success: true } };
1380
+ };
1381
+
1382
+ const phase_run_status = async (table_id, vn, config, body, { req, res }) => {
1383
+ const idx = parseInt(body.idx);
1384
+ const taskType = body.task_type || "feature";
1385
+ const runningMd = await MetaData.findOne({
1386
+ type: "CopilotConstructMgr",
1387
+ name: `phase_running_${idx}_${taskType}`,
1388
+ });
1389
+ const isRunning = !!runningMd;
1390
+ const allTasks = await MetaData.find({
1391
+ type: "CopilotConstructMgr",
1392
+ name: "task",
1393
+ });
1394
+ const runningTask = allTasks.find(
1395
+ (t) =>
1396
+ t.body?.phase_idx === idx &&
1397
+ (t.body?.task_type || "feature") === taskType &&
1398
+ t.body?.status === "Running"
1399
+ );
1400
+ const anyRunning = !!runningTask;
1401
+ return {
1402
+ json: {
1403
+ isRunning,
1404
+ anyRunning,
1405
+ runningTaskName: runningTask?.body?.name || null,
1406
+ },
1407
+ };
1408
+ };
1409
+
1410
+ const del_all_phases = async (table_id, vn, config, body, { req, res }) => {
1411
+ const allTasks = await MetaData.find({
1412
+ type: "CopilotConstructMgr",
1413
+ name: "task",
1414
+ });
1415
+ for (const t of allTasks.filter((t) => t.body?.phase_idx !== undefined))
1416
+ await t.delete();
1417
+ const markers = await MetaData.find({
1418
+ type: "CopilotConstructMgr",
1419
+ name: "phase_plugin_generated",
1420
+ });
1421
+ for (const m of markers) await m.delete();
1422
+ const tablePhase = await MetaData.find({
1423
+ type: "CopilotConstructMgr",
1424
+ name: "table_phase",
1425
+ });
1426
+ for (const m of tablePhase) await m.delete();
1427
+ const viewPhase = await MetaData.find({
1428
+ type: "CopilotConstructMgr",
1429
+ name: "view_phase",
1430
+ });
1431
+ for (const m of viewPhase) await m.delete();
1432
+ const pluginPhaseAll = await MetaData.find({
1433
+ type: "CopilotConstructMgr",
1434
+ name: "plugin_phase",
1435
+ });
1436
+ for (const m of pluginPhaseAll) await m.delete();
1437
+ const phasesMd = await MetaData.findOne({
1438
+ type: "CopilotConstructMgr",
1439
+ name: "phases",
1440
+ });
1441
+ if (phasesMd) await phasesMd.delete();
1442
+ return { json: { success: true } };
1443
+ };
1444
+
1445
+ const phase_progress_html = async (
1446
+ table_id,
1447
+ vn,
1448
+ config,
1449
+ body,
1450
+ { req, res }
1451
+ ) => {
1452
+ const idx = parseInt(body.idx);
1453
+ const html = await phaseProgressHtml(idx);
1454
+ return { json: { html } };
1455
+ };
1456
+
1457
+ const del_phase_progress = async (table_id, vn, config, body, { req, res }) => {
1458
+ const idx = parseInt(body.idx);
1459
+ const all = await MetaData.find({
1460
+ type: "CopilotConstructMgr",
1461
+ name: "progress",
1462
+ });
1463
+ for (const r of all.filter((p) => p.body?.phase_idx === idx))
1464
+ await r.delete();
1465
+ return { json: { success: true } };
1466
+ };
1467
+
1468
+ const phase_routes = {
1469
+ gen_phases,
1470
+ phases_status,
1471
+ phases_html,
1472
+ phase_detail_html,
1473
+ generate_phase_tasks,
1474
+ phase_tasks_status,
1475
+ phase_tasks_html,
1476
+ run_phase_tasks,
1477
+ stop_phase_tasks,
1478
+ phase_run_status,
1479
+ del_phase_type_tasks,
1480
+ del_all_phases,
1481
+ phase_progress_html,
1482
+ del_phase_progress,
1483
+ };
1484
+
1485
+ module.exports = { phasesPanel, phasesStaticScript, phase_routes };