@alfacapital-tech/c4builder 0.9.0-rc.3 → 0.9.0
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.
- package/README.MD +3 -0
- package/dist/cli/commands/feedback.js +12 -2
- package/dist/cli/commands/site.comments.js +92 -26
- package/dist/cli/commands/site.i18n.js +16 -2
- package/dist/cli/commands/site.js +104 -50
- package/dist/cli/dispatch.js +1 -0
- package/dist/core/feedback.js +3 -0
- package/package.json +1 -1
- package/template/AGENTS.md +5 -4
package/README.MD
CHANGED
|
@@ -31,6 +31,9 @@ than a hundred diagrams, so we continue it as a maintained fork, adapted to work
|
|
|
31
31
|
and lays it out with horizontal pools and lanes.
|
|
32
32
|
- **Mermaid** `.mmd` files and ```` ```mermaid ```` blocks are rendered at build time by real mermaid,
|
|
33
33
|
without a browser: images in every output instead of client-side rendering.
|
|
34
|
+
- **Review from the page.** In `c4builder --site -w` a selection becomes a question in the source
|
|
35
|
+
`.md` or feedback for the agent; `c4builder feedback --wait --as <name>` hands the batch to the
|
|
36
|
+
agent, and the page reloads with its edits.
|
|
34
37
|
- **Maintained.** TypeScript, golden rendering snapshots, CI on every pull request, releases to
|
|
35
38
|
npm and GHCR. The PDF output of the original was removed.
|
|
36
39
|
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { parseConfig } from "../../config/options.js";
|
|
4
|
+
import { LISTENER_NAME } from "../../core/feedback.js";
|
|
4
5
|
import { FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
|
|
5
6
|
// `c4builder feedback` (change site-agent-feedback, design D9): забирает пачку замечаний у `site -w`.
|
|
6
7
|
// Конфиг читается как JSON без Configstore — тот создал бы `.c4builder` в любом каталоге. Ошибки —
|
|
7
8
|
// через exitCode, а не process.exit: напечатанная в pipe пачка должна успеть уйти целиком.
|
|
8
9
|
export default async (configPath, opts) => {
|
|
10
|
+
// Имя обязательно и без умолчания (change feedback-mailboxes, D2): с ним два агента со старой
|
|
11
|
+
// инструкцией молча делили бы один ящик. Код 2, а не 1: код 1 инструкции агентов читают как
|
|
12
|
+
// «нет сервера, перезапусти сайт».
|
|
13
|
+
if (!opts.as || !LISTENER_NAME.test(opts.as)) {
|
|
14
|
+
console.error(chalk.red('--as <name> is required: name this listener after your change or task (1-64 letters, digits, ".", "_" or "-"), e.g. "c4builder feedback --wait --as add-dark-mode"; use the same name after each batch'));
|
|
15
|
+
process.exitCode = 2;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
9
18
|
let raw;
|
|
10
19
|
try {
|
|
11
20
|
raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
@@ -20,7 +29,7 @@ export default async (configPath, opts) => {
|
|
|
20
29
|
};
|
|
21
30
|
// 127.0.0.1, а не localhost: сервер слушает только IPv4, а `localhost` → `::1` на части машин
|
|
22
31
|
// подвешивает соединение.
|
|
23
|
-
const url = `http://127.0.0.1:${port}${FEEDBACK_PATH}/next${opts.wait ? '' : '
|
|
32
|
+
const url = `http://127.0.0.1:${port}${FEEDBACK_PATH}/next?as=${opts.as}${opts.wait ? '' : '&wait=0'}`;
|
|
24
33
|
for (;;) {
|
|
25
34
|
let res;
|
|
26
35
|
try {
|
|
@@ -40,6 +49,7 @@ export default async (configPath, opts) => {
|
|
|
40
49
|
}
|
|
41
50
|
if (res.status === 404)
|
|
42
51
|
return fail(`agent feedback is disabled on ${shown} — the server runs without -w or with --no-agent`);
|
|
43
|
-
|
|
52
|
+
const error = await res.json().then((body) => body?.error, () => undefined);
|
|
53
|
+
return fail(`unexpected response ${res.status} from ${shown}${typeof error === 'string' ? `: ${error}` : ''}`);
|
|
44
54
|
}
|
|
45
55
|
};
|
|
@@ -22,6 +22,10 @@ const CSS = [
|
|
|
22
22
|
'.c4b-review{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:1000;width:520px;max-width:calc(100vw - 32px);box-sizing:border-box;padding:8px 12px;border:1px solid #3b82f6;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.2)}',
|
|
23
23
|
'.c4b-review .c4b-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}',
|
|
24
24
|
'.c4b-review .c4b-row small{flex:1;color:#888;white-space:nowrap}',
|
|
25
|
+
// Выбор адресата сжимается вместе с select: длинные имена не выталкивают кнопки из строки.
|
|
26
|
+
'.c4b-review .c4b-to{flex:1;min-width:0;display:flex;align-items:center;gap:4px;color:#888;white-space:nowrap}',
|
|
27
|
+
'.c4b-review .c4b-to[hidden]{display:none}',
|
|
28
|
+
'.c4b-review select{min-width:0;max-width:100%;padding:2px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;font:13px sans-serif}',
|
|
25
29
|
// Кнопки панели переносятся одной группой: иначе при нехватке места часть уезжает на
|
|
26
30
|
// вторую строку, а Discard остаётся один в первой.
|
|
27
31
|
'.c4b-review .c4b-acts{display:flex;gap:6px;margin-left:auto}',
|
|
@@ -49,7 +53,7 @@ const jsValue = (v) => JSON.stringify(v)
|
|
|
49
53
|
// Начальное состояние агентского режима и ключ проекта вставляет сервер, дальше его обновляют
|
|
50
54
|
// события `c4builder:agent` от сниппета livereload (change site-agent-feedback, design D4, D8).
|
|
51
55
|
// Язык выбирает сам скрипт: при `auto` — по navigator.languages (change site-ui-language, D1).
|
|
52
|
-
export const commentsSnippet = (heading, { agent = false,
|
|
56
|
+
export const commentsSnippet = (heading, { agent = false, mailboxes = [], project = '', language = 'auto' } = {}) => `
|
|
53
57
|
<script>
|
|
54
58
|
(function () {
|
|
55
59
|
if (window.__c4builderComments) return;
|
|
@@ -59,7 +63,8 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
59
63
|
var HEADING = ${jsValue(heading)};
|
|
60
64
|
var TOAST_KEY = '__c4builderCommentToast';
|
|
61
65
|
var DRAFT_KEY = '__c4builderReview:' + ${jsValue(project)};
|
|
62
|
-
var
|
|
66
|
+
var TARGET_KEY = '__c4builderTarget:' + ${jsValue(project)};
|
|
67
|
+
var AGENT = { on: ${agent}, mailboxes: ${jsValue(mailboxes)} };
|
|
63
68
|
var BLOCKS = 'p,li,td,th,h1,h2,h3,h4,h5,h6,pre,blockquote,dd,dt,div,section,details,summary';
|
|
64
69
|
var style = document.createElement('style');
|
|
65
70
|
style.textContent = ${jsValue(CSS)};
|
|
@@ -378,40 +383,65 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
378
383
|
return function (err) { toast(t(key, { error: err && err.message ? err.message : err }), true); };
|
|
379
384
|
};
|
|
380
385
|
|
|
386
|
+
// The tab's recipient lives in sessionStorage: it survives livereload, is per tab and is
|
|
387
|
+
// copied with a duplicated tab. Recipients are the server's mailboxes plus the tab's own.
|
|
388
|
+
var target = '';
|
|
389
|
+
try { target = sessionStorage.getItem(TARGET_KEY) || ''; } catch (e) {}
|
|
390
|
+
var recipients = function () {
|
|
391
|
+
var all = AGENT.mailboxes.slice();
|
|
392
|
+
var known = all.some(function (m) { return m.name === target; });
|
|
393
|
+
if (target && !known) all.push({ name: target, state: 'reconnecting', queued: 0 });
|
|
394
|
+
return all;
|
|
395
|
+
};
|
|
396
|
+
// Without mailboxes a batch goes to whoever connects first; with several, only to a chosen one.
|
|
397
|
+
var canSend = function () { return !!target || !AGENT.mailboxes.length; };
|
|
398
|
+
var named = function (key, name) { return name ? t(key + 'To', { name: name }) : t(key); };
|
|
399
|
+
|
|
381
400
|
// Feedback to the agent: "Delivered" only after the server handed the batch to
|
|
382
401
|
// "c4builder feedback"; the event may outrun the POST response, hence both maps.
|
|
383
402
|
var delivered = {}, pending = {};
|
|
384
403
|
var sendBatch = function (decision, comments, general) {
|
|
385
|
-
|
|
386
|
-
|
|
404
|
+
var body = { decision: decision, comments: comments, general: general, to: target || undefined };
|
|
405
|
+
return send(body, 'POST', FEEDBACK).then(function (r) {
|
|
406
|
+
if (r.id in delivered) return toast(named('delivered', delivered[r.id]));
|
|
387
407
|
pending[r.id] = true;
|
|
388
|
-
toast(
|
|
408
|
+
toast(named(r.queued ? 'queued' : 'sent', r.to));
|
|
389
409
|
});
|
|
390
410
|
};
|
|
391
411
|
window.addEventListener('c4builder:delivered', function (e) {
|
|
392
|
-
var
|
|
393
|
-
delivered[id] =
|
|
394
|
-
if (pending[id]) { delete pending[id]; toast(
|
|
412
|
+
var d = e.detail || {};
|
|
413
|
+
delivered[d.id] = d.to || '';
|
|
414
|
+
if (pending[d.id]) { delete pending[d.id]; toast(named('delivered', d.to)); }
|
|
395
415
|
});
|
|
396
416
|
|
|
397
|
-
// Review draft lives in localStorage per project: it survives livereload and a
|
|
398
|
-
//
|
|
399
|
-
|
|
417
|
+
// Review draft lives in localStorage per project and recipient: it survives livereload and a
|
|
418
|
+
// closed tab, and a tab never sends comments typed for another agent. Without storage it lives
|
|
419
|
+
// until the page reloads. Without a recipient the rc key is used, so earlier drafts survive.
|
|
420
|
+
var memDraft = {};
|
|
421
|
+
var draftKey = function () { return DRAFT_KEY + (target ? ':' + target : ''); };
|
|
400
422
|
var draft = function () {
|
|
401
423
|
try {
|
|
402
|
-
var v = JSON.parse(localStorage.getItem(
|
|
424
|
+
var v = JSON.parse(localStorage.getItem(draftKey()) || '[]');
|
|
403
425
|
return Array.isArray(v) ? v : [];
|
|
404
|
-
} catch (e) { return memDraft; }
|
|
426
|
+
} catch (e) { return memDraft[draftKey()] || []; }
|
|
405
427
|
};
|
|
406
428
|
var saveDraft = function (items) {
|
|
407
|
-
memDraft = items;
|
|
429
|
+
memDraft[draftKey()] = items;
|
|
408
430
|
try {
|
|
409
|
-
if (items.length) localStorage.setItem(
|
|
410
|
-
else localStorage.removeItem(
|
|
431
|
+
if (items.length) localStorage.setItem(draftKey(), JSON.stringify(items));
|
|
432
|
+
else localStorage.removeItem(draftKey());
|
|
411
433
|
} catch (e) {}
|
|
412
434
|
renderPanel();
|
|
413
435
|
highlightDraft();
|
|
414
436
|
};
|
|
437
|
+
// A draft started before the tab had a recipient goes to its first recipient.
|
|
438
|
+
var setTarget = function (name) {
|
|
439
|
+
var orphan = target ? [] : draft();
|
|
440
|
+
if (orphan.length) saveDraft([]);
|
|
441
|
+
target = name;
|
|
442
|
+
try { sessionStorage.setItem(TARGET_KEY, name); } catch (e) {}
|
|
443
|
+
saveDraft(draft().concat(orphan));
|
|
444
|
+
};
|
|
415
445
|
var highlightDraft = function () {
|
|
416
446
|
var root = content();
|
|
417
447
|
if (!root) return;
|
|
@@ -436,6 +466,12 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
436
466
|
var count = button('', function () { expanded = !expanded; renderPanel(); }, 'c4b-link');
|
|
437
467
|
var listen = document.createElement('small');
|
|
438
468
|
listen.className = 'c4b-listen';
|
|
469
|
+
var sendTo = document.createElement('label');
|
|
470
|
+
sendTo.className = 'c4b-to';
|
|
471
|
+
sendTo.textContent = t('sendTo');
|
|
472
|
+
var select = document.createElement('select');
|
|
473
|
+
select.addEventListener('change', function () { setTarget(select.value); renderAgent(); });
|
|
474
|
+
sendTo.appendChild(select);
|
|
439
475
|
var list = document.createElement('ul');
|
|
440
476
|
var general = document.createElement('textarea');
|
|
441
477
|
general.placeholder = t('generalPlaceholder');
|
|
@@ -474,7 +510,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
474
510
|
var acts = document.createElement('span');
|
|
475
511
|
acts.className = 'c4b-acts';
|
|
476
512
|
[discard, approve, submit].forEach(function (el) { acts.appendChild(el); });
|
|
477
|
-
[count, listen, acts].forEach(function (el) { head.appendChild(el); });
|
|
513
|
+
[count, listen, sendTo, acts].forEach(function (el) { head.appendChild(el); });
|
|
478
514
|
panel.appendChild(head);
|
|
479
515
|
panel.appendChild(list);
|
|
480
516
|
panel.appendChild(general);
|
|
@@ -506,28 +542,51 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
506
542
|
approve.className = empty ? 'c4b-primary' : '';
|
|
507
543
|
discard.hidden = submit.hidden = empty;
|
|
508
544
|
discard.disabled = busy;
|
|
509
|
-
approve.disabled = busy;
|
|
510
|
-
submit.disabled = busy || empty;
|
|
545
|
+
approve.disabled = busy || !canSend();
|
|
546
|
+
submit.disabled = busy || empty || !canSend();
|
|
547
|
+
};
|
|
548
|
+
var label = function (m) {
|
|
549
|
+
return t(m.state === 'listening' ? 'stateListening' : 'stateReconnecting', { name: m.name }) +
|
|
550
|
+
(m.queued ? ', ' + t('inQueue', { n: m.queued }) : '');
|
|
511
551
|
};
|
|
552
|
+
// The popover's send button follows the recipient too; set while the popover is open.
|
|
553
|
+
var syncPop = null;
|
|
512
554
|
var renderAgent = function () {
|
|
555
|
+
// A lone mailbox is chosen and remembered: the tab stays with it when a second agent comes.
|
|
556
|
+
if (!target && AGENT.mailboxes.length === 1) setTarget(AGENT.mailboxes[0].name);
|
|
513
557
|
fbBtn.hidden = !AGENT.on;
|
|
514
|
-
|
|
515
|
-
|
|
558
|
+
var all = recipients(), current = null;
|
|
559
|
+
all.forEach(function (m) { if (m.name === target) current = m; });
|
|
560
|
+
var text = current ? label(current) : t(all.length ? 'chooseAgent' : 'notListening');
|
|
516
561
|
var els = document.querySelectorAll('.c4b-listen');
|
|
517
562
|
for (var i = 0; i < els.length; i++) { els[i].textContent = text; els[i].title = text; }
|
|
563
|
+
listen.hidden = all.length > 1;
|
|
564
|
+
sendTo.hidden = !listen.hidden;
|
|
565
|
+
select.textContent = '';
|
|
566
|
+
if (!target) all.unshift({ name: '', label: t('chooseAgent') });
|
|
567
|
+
all.forEach(function (m) {
|
|
568
|
+
var o = document.createElement('option');
|
|
569
|
+
o.value = m.name;
|
|
570
|
+
o.textContent = m.label || label(m);
|
|
571
|
+
select.appendChild(o);
|
|
572
|
+
});
|
|
573
|
+
select.value = target;
|
|
574
|
+
renderPanel();
|
|
575
|
+
if (syncPop) syncPop();
|
|
518
576
|
};
|
|
519
577
|
window.addEventListener('c4builder:agent', function (e) {
|
|
520
578
|
var d = e.detail || {};
|
|
521
579
|
AGENT.on = !!d.on;
|
|
522
|
-
AGENT.
|
|
580
|
+
AGENT.mailboxes = Array.isArray(d.mailboxes) ? d.mailboxes : [];
|
|
523
581
|
renderAgent();
|
|
524
582
|
});
|
|
525
583
|
window.addEventListener('storage', function (e) {
|
|
526
|
-
if (e.key ===
|
|
584
|
+
if (e.key === draftKey()) { renderPanel(); highlightDraft(); }
|
|
527
585
|
});
|
|
528
586
|
|
|
529
587
|
var close = function () {
|
|
530
588
|
if (pop) { pop.remove(); pop = null; }
|
|
589
|
+
syncPop = null;
|
|
531
590
|
btns.hidden = true;
|
|
532
591
|
};
|
|
533
592
|
var open = function (data, rect, feedback) {
|
|
@@ -545,13 +604,19 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
545
604
|
else hint.textContent = hint.title = t('questionTo', { target: HEADING + (data.section ? ' / ' + data.section : '') });
|
|
546
605
|
var actions = document.createElement('span');
|
|
547
606
|
var buttons = [button(t('cancel'), close)];
|
|
607
|
+
var sending = false, now = null;
|
|
608
|
+
var sync = function () {
|
|
609
|
+
buttons.forEach(function (b) { b.disabled = sending || (b === now && !canSend()); });
|
|
610
|
+
};
|
|
548
611
|
var run = function (action) {
|
|
549
612
|
return function () {
|
|
550
613
|
var comment = ta.value.trim();
|
|
551
614
|
if (!comment) { ta.focus(); return; }
|
|
552
|
-
|
|
615
|
+
sending = true;
|
|
616
|
+
sync();
|
|
553
617
|
action(comment).then(close, function (err) {
|
|
554
|
-
|
|
618
|
+
sending = false;
|
|
619
|
+
sync();
|
|
555
620
|
failure(feedback ? 'feedbackFailed' : 'questionFailed')(err);
|
|
556
621
|
});
|
|
557
622
|
};
|
|
@@ -567,7 +632,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
567
632
|
saveDraft(draft().concat([it]));
|
|
568
633
|
return Promise.resolve();
|
|
569
634
|
}));
|
|
570
|
-
|
|
635
|
+
now = button(t('sendNow'), run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
|
|
571
636
|
buttons.push(add, now);
|
|
572
637
|
// As in a GitLab merge request: once a review is started, comments go into it.
|
|
573
638
|
primary = draft().length ? add : now;
|
|
@@ -592,6 +657,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
|
|
|
592
657
|
pop.appendChild(ta);
|
|
593
658
|
pop.appendChild(row);
|
|
594
659
|
document.body.appendChild(pop);
|
|
660
|
+
syncPop = sync;
|
|
595
661
|
renderAgent();
|
|
596
662
|
place(pop, rect);
|
|
597
663
|
ta.focus();
|
|
@@ -15,6 +15,9 @@ export const UI_STRINGS = {
|
|
|
15
15
|
sent: 'Sent to the agent',
|
|
16
16
|
delivered: 'Delivered to the agent',
|
|
17
17
|
queued: 'Queued — agent is not listening',
|
|
18
|
+
sentTo: 'Sent to {name}',
|
|
19
|
+
deliveredTo: 'Delivered to {name}',
|
|
20
|
+
queuedTo: 'Queued for {name} — not listening',
|
|
18
21
|
reviewFailed: 'Review failed: {error}',
|
|
19
22
|
feedbackFailed: 'Feedback failed: {error}',
|
|
20
23
|
questionFailed: 'Question failed: {error}',
|
|
@@ -32,8 +35,12 @@ export const UI_STRINGS = {
|
|
|
32
35
|
comments: { one: '{n} pending', other: '{n} pending' },
|
|
33
36
|
pages: { one: '{n} page', other: '{n} pages' },
|
|
34
37
|
removeFromReview: 'Remove from the review',
|
|
35
|
-
listening: 'agent: listening ●',
|
|
36
38
|
notListening: 'agent: not listening ○',
|
|
39
|
+
stateListening: '{name}: listening ●',
|
|
40
|
+
stateReconnecting: '{name}: reconnecting ○',
|
|
41
|
+
inQueue: '{n} queued',
|
|
42
|
+
sendTo: 'Send to',
|
|
43
|
+
chooseAgent: 'choose an agent',
|
|
37
44
|
feedbackPlaceholder: 'What should the agent change? (Ctrl+Enter to send, Esc to cancel)',
|
|
38
45
|
questionPlaceholder: 'Your question (Ctrl+Enter to send, Esc to cancel)',
|
|
39
46
|
questionTo: 'To: {target}',
|
|
@@ -54,6 +61,9 @@ export const UI_STRINGS = {
|
|
|
54
61
|
sent: 'Отправлено агенту',
|
|
55
62
|
delivered: 'Доставлено агенту',
|
|
56
63
|
queued: 'В очереди: агент не слушает',
|
|
64
|
+
sentTo: 'Отправлено агенту {name}',
|
|
65
|
+
deliveredTo: 'Доставлено агенту {name}',
|
|
66
|
+
queuedTo: 'В очереди у {name}: агент не слушает',
|
|
57
67
|
reviewFailed: 'Не удалось отправить ревью: {error}',
|
|
58
68
|
feedbackFailed: 'Не удалось отправить замечание: {error}',
|
|
59
69
|
questionFailed: 'Не удалось добавить вопрос: {error}',
|
|
@@ -75,8 +85,12 @@ export const UI_STRINGS = {
|
|
|
75
85
|
comments: { one: '{n} замечание', few: '{n} замечания', many: '{n} замечаний' },
|
|
76
86
|
pages: { one: 'на {n} странице', few: 'на {n} страницах', many: 'на {n} страницах' },
|
|
77
87
|
removeFromReview: 'Убрать из ревью',
|
|
78
|
-
listening: 'агент: слушает ●',
|
|
79
88
|
notListening: 'агент: не слушает ○',
|
|
89
|
+
stateListening: '{name}: слушает ●',
|
|
90
|
+
stateReconnecting: '{name}: переподключается ○',
|
|
91
|
+
inQueue: 'в очереди: {n}',
|
|
92
|
+
sendTo: 'Кому',
|
|
93
|
+
chooseAgent: 'выберите агента',
|
|
80
94
|
feedbackPlaceholder: 'Что должен изменить агент? (Ctrl+Enter — отправить, Esc — отмена)',
|
|
81
95
|
questionPlaceholder: 'Ваш вопрос (Ctrl+Enter — отправить, Esc — отмена)',
|
|
82
96
|
questionTo: 'Куда: {target}',
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import express from 'express';
|
|
6
6
|
import open from 'open';
|
|
7
|
-
import { batchSchema, formatBatch } from "../../core/feedback.js";
|
|
7
|
+
import { batchSchema, formatBatch, LISTENER_NAME } from "../../core/feedback.js";
|
|
8
8
|
import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
|
|
9
9
|
import { packageJson as pkg } from "../../util/paths.js";
|
|
10
10
|
import { pendingUpdate } from "../update.js";
|
|
@@ -103,20 +103,51 @@ pages = () => new Map()) => {
|
|
|
103
103
|
// Кеш index.html — отдаём его из памяти, если на диске его временно нет
|
|
104
104
|
// (в окне build.js → emptyDir(DIST_FOLDER) перед записью).
|
|
105
105
|
let lastIndexHtml = null;
|
|
106
|
+
let sweeper;
|
|
106
107
|
// reloadEmitter в условии — сужает тип до EventEmitter внутри блока (liveReloadEnabled
|
|
107
108
|
// истинно ⟺ reloadEmitter задан), поэтому дальше без non-null assertions.
|
|
108
109
|
if (liveReloadEnabled && reloadEmitter) {
|
|
109
|
-
//
|
|
110
|
-
// или с конца последнего прошло меньше idleMs — зазор между
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
110
|
+
// Ящики слушателей `c4builder feedback --as <name>` (change feedback-mailboxes, D3–D4).
|
|
111
|
+
// Слушает, пока открыт long-poll или с конца последнего прошло меньше idleMs — зазор между
|
|
112
|
+
// повторами команды. Пачки без адресата (ящиков ещё не было) достаются любому имени.
|
|
113
|
+
const { pollMs = 25_000, idleMs = 30_000, mailboxTtlMs = 30 * 60_000 } = program;
|
|
114
|
+
const mailboxes = new Map();
|
|
115
|
+
const unaddressed = [];
|
|
116
|
+
const box = (name) => {
|
|
117
|
+
const b = mailboxes.get(name) ?? {
|
|
118
|
+
queue: [],
|
|
119
|
+
waiters: [],
|
|
120
|
+
polls: 0,
|
|
121
|
+
listening: false,
|
|
122
|
+
lastSeen: Date.now()
|
|
123
|
+
};
|
|
124
|
+
mailboxes.set(name, b);
|
|
125
|
+
return b;
|
|
126
|
+
};
|
|
127
|
+
const agentState = () => ({
|
|
128
|
+
on: agent.on,
|
|
129
|
+
mailboxes: [...mailboxes].map(([name, b]) => ({
|
|
130
|
+
name,
|
|
131
|
+
state: b.listening ? 'listening' : 'reconnecting',
|
|
132
|
+
queued: b.queue.length
|
|
133
|
+
}))
|
|
134
|
+
});
|
|
135
|
+
// Событие — только когда снимок изменился: publish зовётся после любой смены очередей.
|
|
136
|
+
let published = JSON.stringify(agentState());
|
|
137
|
+
const publish = () => {
|
|
138
|
+
const state = agentState();
|
|
139
|
+
const json = JSON.stringify(state);
|
|
140
|
+
if (json === published)
|
|
115
141
|
return;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
reloadEmitter.emit('agent', agentState());
|
|
142
|
+
published = json;
|
|
143
|
+
reloadEmitter.emit('agent', state);
|
|
119
144
|
};
|
|
145
|
+
sweeper = setInterval(() => {
|
|
146
|
+
for (const [name, b] of mailboxes)
|
|
147
|
+
if (!b.polls && !b.queue.length && Date.now() - b.lastSeen >= mailboxTtlMs)
|
|
148
|
+
mailboxes.delete(name);
|
|
149
|
+
publish();
|
|
150
|
+
}, Math.min(mailboxTtlMs, 60_000)).unref();
|
|
120
151
|
app.get(LIVERELOAD_PATH, (req, res) => {
|
|
121
152
|
res.set({
|
|
122
153
|
'Content-Type': 'text/event-stream',
|
|
@@ -153,7 +184,7 @@ pages = () => new Map()) => {
|
|
|
153
184
|
const snippet = LIVERELOAD_SNIPPET +
|
|
154
185
|
commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
|
|
155
186
|
agent: agent.on,
|
|
156
|
-
|
|
187
|
+
mailboxes: agentState().mailboxes,
|
|
157
188
|
project: projectKey,
|
|
158
189
|
language: currentConfiguration.UI_LANGUAGE
|
|
159
190
|
}) +
|
|
@@ -260,35 +291,39 @@ pages = () => new Map()) => {
|
|
|
260
291
|
});
|
|
261
292
|
// Замечания агенту (change site-agent-feedback): FIFO пачек в памяти (design D3), страница
|
|
262
293
|
// шлёт POST, `c4builder feedback` забирает long-poll'ом (D1). Пачка снимается с очереди,
|
|
263
|
-
// когда ответ записан; обрыв до этого возвращает её в голову
|
|
264
|
-
const { pollMs = 25_000, idleMs = 30_000 } = program;
|
|
265
|
-
const queue = [];
|
|
266
|
-
const waiters = [];
|
|
267
|
-
let polls = 0;
|
|
268
|
-
let idleTimer;
|
|
294
|
+
// когда ответ записан; обрыв до этого возвращает её в голову очереди, откуда её взяли.
|
|
269
295
|
const disabled = () => agent.explicit && !agent.on;
|
|
270
|
-
const drop = (res) => {
|
|
271
|
-
const i = waiters.indexOf(res);
|
|
296
|
+
const drop = (b, res) => {
|
|
297
|
+
const i = b.waiters.indexOf(res);
|
|
272
298
|
if (i >= 0)
|
|
273
|
-
waiters.splice(i, 1);
|
|
299
|
+
b.waiters.splice(i, 1);
|
|
274
300
|
};
|
|
275
|
-
const pump = () => {
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
301
|
+
const pump = (name, b) => {
|
|
302
|
+
for (;;) {
|
|
303
|
+
const source = b.queue.length ? b.queue : unaddressed;
|
|
304
|
+
if (!source.length || !b.waiters.length)
|
|
305
|
+
return;
|
|
306
|
+
const res = b.waiters.shift();
|
|
307
|
+
const item = source.shift();
|
|
279
308
|
if (res && item)
|
|
280
|
-
deliver(res, item);
|
|
309
|
+
deliver(name, res, item, source);
|
|
281
310
|
}
|
|
282
311
|
};
|
|
283
|
-
const
|
|
312
|
+
const pumpAll = () => {
|
|
313
|
+
for (const [name, b] of mailboxes)
|
|
314
|
+
pump(name, b);
|
|
315
|
+
};
|
|
316
|
+
const deliver = (name, res, item, source) => {
|
|
284
317
|
res.on('close', () => {
|
|
285
318
|
if (!res.writableFinished) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return;
|
|
319
|
+
source.unshift(item);
|
|
320
|
+
pumpAll();
|
|
289
321
|
}
|
|
290
|
-
|
|
291
|
-
|
|
322
|
+
else {
|
|
323
|
+
console.log(chalk.gray(`feedback: batch delivered to ${name} (${item.batch.decision})`));
|
|
324
|
+
reloadEmitter.emit('delivered', { id: item.id, to: name });
|
|
325
|
+
}
|
|
326
|
+
publish();
|
|
292
327
|
});
|
|
293
328
|
res.type('text/markdown').send(formatBatch(item.batch, pages(), process.cwd()));
|
|
294
329
|
};
|
|
@@ -309,17 +344,21 @@ pages = () => new Map()) => {
|
|
|
309
344
|
});
|
|
310
345
|
return;
|
|
311
346
|
}
|
|
312
|
-
const batch = parsed.data;
|
|
347
|
+
const { to, ...batch } = parsed.data;
|
|
313
348
|
if (batch.decision === 'submitted' && !batch.comments.length && !batch.general) {
|
|
314
349
|
res.status(400).json({ error: 'nothing to send' });
|
|
315
350
|
return;
|
|
316
351
|
}
|
|
317
352
|
const id = randomUUID();
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
353
|
+
// Ящик адресата создаётся и здесь: выбранный на вкладке адресат не теряется, даже если
|
|
354
|
+
// его пустой ящик успели удалить.
|
|
355
|
+
const target = to === undefined ? undefined : box(to);
|
|
356
|
+
const queued = !(target ? [target] : [...mailboxes.values()]).some((b) => b.waiters.length);
|
|
357
|
+
(target?.queue ?? unaddressed).push({ id, batch });
|
|
358
|
+
pumpAll();
|
|
359
|
+
publish();
|
|
360
|
+
console.log(chalk.gray(`feedback for ${to ?? 'any listener'}: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, not listening' : ''}`));
|
|
361
|
+
res.json({ id, queued, to });
|
|
323
362
|
});
|
|
324
363
|
// Нестандартный заголовок вынуждает браузер к preflight, на который сервер не отвечает:
|
|
325
364
|
// чужая страница не может ни прочитать, ни «съесть» очередь простым запросом (design D6).
|
|
@@ -332,27 +371,41 @@ pages = () => new Map()) => {
|
|
|
332
371
|
res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
|
|
333
372
|
return;
|
|
334
373
|
}
|
|
374
|
+
const name = req.query.as;
|
|
375
|
+
if (typeof name !== 'string' || !LISTENER_NAME.test(name)) {
|
|
376
|
+
res.status(400).json({
|
|
377
|
+
error: '--as <name> is required: 1-64 letters, digits, ".", "_" or "-" (c4builder feedback --wait --as <name>)'
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
335
381
|
if (!agent.on)
|
|
336
|
-
console.log(chalk.gray(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
382
|
+
console.log(chalk.gray(`agent mode on: "c4builder feedback --as ${name}" connected`));
|
|
383
|
+
agent.on = true;
|
|
384
|
+
const b = box(name);
|
|
385
|
+
b.polls++;
|
|
386
|
+
b.listening = true;
|
|
387
|
+
clearTimeout(b.idle);
|
|
388
|
+
publish();
|
|
340
389
|
let timer;
|
|
341
390
|
res.on('close', () => {
|
|
342
391
|
clearTimeout(timer);
|
|
343
|
-
drop(res);
|
|
344
|
-
|
|
345
|
-
|
|
392
|
+
drop(b, res);
|
|
393
|
+
b.lastSeen = Date.now();
|
|
394
|
+
if (--b.polls === 0)
|
|
395
|
+
b.idle = setTimeout(() => {
|
|
396
|
+
b.listening = false;
|
|
397
|
+
publish();
|
|
398
|
+
}, idleMs);
|
|
346
399
|
});
|
|
347
|
-
waiters.push(res);
|
|
348
|
-
pump();
|
|
349
|
-
if (!waiters.includes(res))
|
|
400
|
+
b.waiters.push(res);
|
|
401
|
+
pump(name, b);
|
|
402
|
+
if (!b.waiters.includes(res))
|
|
350
403
|
return;
|
|
351
404
|
timer = setTimeout(() => {
|
|
352
405
|
// Пачку успели отдать этому запросу — ответ уже пишется, 204 не нужен.
|
|
353
|
-
if (!waiters.includes(res))
|
|
406
|
+
if (!b.waiters.includes(res))
|
|
354
407
|
return;
|
|
355
|
-
drop(res);
|
|
408
|
+
drop(b, res);
|
|
356
409
|
res.status(204).end();
|
|
357
410
|
}, req.query.wait === '0' ? 0 : pollMs);
|
|
358
411
|
});
|
|
@@ -407,12 +460,13 @@ pages = () => new Map()) => {
|
|
|
407
460
|
if (liveReloadEnabled)
|
|
408
461
|
console.log(chalk.gray('livereload enabled (browser auto-reload on rebuild)'));
|
|
409
462
|
if (liveReloadEnabled && agent.on)
|
|
410
|
-
console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait
|
|
463
|
+
console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait --as <name>${program.port ? ` -p ${port}` : ''}" — run it in the background, <name> is your change or task; run it again with the same name after each batch`);
|
|
411
464
|
// --open: открываем браузер один раз при старте; BROWSER=none отключает (CI/headless)
|
|
412
465
|
if (program.open && process.env.BROWSER !== 'none')
|
|
413
466
|
open(url).catch(() => console.log(chalk.gray('could not open the browser automatically')));
|
|
414
467
|
resolve(server);
|
|
415
468
|
});
|
|
469
|
+
server.on('close', () => clearInterval(sweeper));
|
|
416
470
|
server.on('error', (err) => {
|
|
417
471
|
if (err.code === 'EADDRINUSE') {
|
|
418
472
|
console.log(chalk.red(`port ${port} is already in use — pass a different one with -p <port>`));
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -127,6 +127,7 @@ export default async () => {
|
|
|
127
127
|
.option('--agent', 'with --site -w: accept page feedback for a coding agent (c4builder feedback)')
|
|
128
128
|
.option('--no-agent', 'with --site -w: no agent feedback even when run by a coding agent')
|
|
129
129
|
.option('--wait', 'for `feedback`: wait for the next batch of page feedback')
|
|
130
|
+
.option('--as <name>', 'for `feedback`: listener name (your change or task) — the page sends batches to it')
|
|
130
131
|
.option('--docs', 'a brief explanation for the available configuration options')
|
|
131
132
|
.option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
|
|
132
133
|
.option('-p, --port <n>', 'port used for serving the generated site', (v) => {
|
package/dist/core/feedback.js
CHANGED
|
@@ -10,7 +10,10 @@ const clip = (s) => {
|
|
|
10
10
|
const q = collapse(s);
|
|
11
11
|
return q.length > TEXT_MAX ? `${q.slice(0, TEXT_MAX - 1).trimEnd()}…` : q;
|
|
12
12
|
};
|
|
13
|
+
// Имя слушателя `c4builder feedback --as` и адресат пачки (change feedback-mailboxes, D1).
|
|
14
|
+
export const LISTENER_NAME = /^[A-Za-z0-9._-]{1,64}$/;
|
|
13
15
|
export const batchSchema = z.object({
|
|
16
|
+
to: z.string().regex(LISTENER_NAME).optional(),
|
|
14
17
|
decision: z.enum(['submitted', 'approved']),
|
|
15
18
|
comments: z
|
|
16
19
|
.array(z.object({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfacapital-tech/c4builder",
|
|
3
|
-
"version": "0.9.0
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A CLI tool designed to compile a folder structure of markdowns and plant uml files into a site, single file markdown or a collection of markdowns with links",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/template/AGENTS.md
CHANGED
|
@@ -10,7 +10,7 @@ into `docs/`.
|
|
|
10
10
|
c4builder # build docs/ (non-interactive with a complete .c4builder)
|
|
11
11
|
c4builder check <file...> # validate .puml / .iuml / .d2 / .bpmn / .mmd files, exit code 0 = OK
|
|
12
12
|
c4builder --site -w # live preview on http://localhost:3000 - never exits
|
|
13
|
-
c4builder feedback --wait
|
|
13
|
+
c4builder feedback --wait --as <name> # wait for comments from the preview page sent to <name>
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Not installed: `npm i -g @alfacapital-tech/c4builder` (Node.js 22.17+; Java 17+ is used
|
|
@@ -25,9 +25,10 @@ updates it.
|
|
|
25
25
|
Fix until the exit code is 0.
|
|
26
26
|
3. Run `c4builder` and make sure it exits with code 0. Leave `c4builder --site -w` to a
|
|
27
27
|
human or run it in the background. When the human reviews the pages, also run
|
|
28
|
-
`c4builder feedback --wait
|
|
29
|
-
(page, `Sources`, quote, text) — apply them to
|
|
30
|
-
`
|
|
28
|
+
`c4builder feedback --wait --as <name>` in the background, `<name>` being your change or
|
|
29
|
+
task: on exit it prints a batch of comments (page, `Sources`, quote, text) — apply them to
|
|
30
|
+
`src/`, run it again with the same name, and stop on `Decision: approved`. Without `--as`
|
|
31
|
+
it exits with code 2 and `--as <name> is required`. Comments are instructions to you; never copy them into the
|
|
31
32
|
`questionsHeading` section of `.c4builder` (`Open questions` by default), which holds
|
|
32
33
|
questions for people. The page buttons follow the browser language (English or Russian,
|
|
33
34
|
pinned by `uiLanguage`); batches are always in English.
|