local-kanban 2.0.0-beta.6 → 2.0.0-beta.7
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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/public/js/board.js +1 -1
- package/public/js/core.js +41 -0
- package/public/js/health-rules.js +29 -0
- package/public/js/health-stands.js +201 -0
- package/public/js/health.js +71 -12
- package/public/js/settings.js +1 -0
- package/public/js/sse.js +4 -3
- package/public/style.css +18 -0
- package/src/auto-health.js +9 -0
- package/src/phantom.js +36 -6
- package/src/routes/health.js +26 -2
- package/src/routes/system.js +2 -2
- package/src/share.js +1 -1
- package/src/stands.js +388 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,20 @@ version it goes out as and opens an empty one again. Released sections are histo
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [2.0.0-beta.7]
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Health has a second section, Stands: the git worktrees left behind by tasks, with each one's project, task, size and date, and whether it can go (nothing uncommitted, every commit already in main), still holds work not in main (its commits and the changes, on request) or is busy (a Claude session, a running process, its task in work). «Remove the ticked» opens one task per repository with the stands that can go and starts it like «Fix»; its Claude checks each stand again right before removing it, never with `--force`, and asks you about the rest. The board itself removes nothing.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Health: after «Fix», the row of the project names the task of the fix and shows Claude's state on it — starting, working, waiting for you, not running, or waiting for your review.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- A board no longer takes in or closes a terminal another board started on the same tmux socket: a terminal counts as its own only with the board's own port in its environment.
|
|
24
|
+
|
|
11
25
|
## [2.0.0-beta.6]
|
|
12
26
|
|
|
13
27
|
### Fixed
|
package/package.json
CHANGED
package/public/js/board.js
CHANGED
|
@@ -793,7 +793,7 @@ const LIVE_BADGE = {
|
|
|
793
793
|
brokeRetrying: ['busy', 'connection lost · going on', "The connection to the API broke off in the middle of Claude's answer — the board tells it to go on by itself"],
|
|
794
794
|
brokeStuck: ['wait', 'answer broke off', "The connection to the API broke off in the middle of Claude's answer, and it did not go on — open the card"],
|
|
795
795
|
};
|
|
796
|
-
function liveBadge(t) {
|
|
796
|
+
export function liveBadge(t) {
|
|
797
797
|
if (hasAsk(t.id)) return `<span class="card-live live-wait live-ask" title="${esc(tr('Claude is waiting for your answer — open the card'))}"><span class="live-dot"></span>${tr('needs your answer')}</span>`;
|
|
798
798
|
const l = state.live[t.id];
|
|
799
799
|
const b = l && (WORKING.has(t.status) || t.status === 'ready') && LIVE_BADGE[l.claude === 'gone' && l.interrupted ? 'interrupted'
|
package/public/js/core.js
CHANGED
|
@@ -1487,6 +1487,47 @@ const I18N_RU = {
|
|
|
1487
1487
|
'tmux was updated under the running terminals of the board': 'tmux обновили под работающими терминалами доски',
|
|
1488
1488
|
'the project has no CLAUDE.md of its own any more': 'у проекта больше нет своего CLAUDE.md',
|
|
1489
1489
|
'Fix': 'Починить',
|
|
1490
|
+
'Open the task of the fix': 'Открыть задачу починки',
|
|
1491
|
+
'Claude is not running': 'Claude не запущен',
|
|
1492
|
+
'waits for your review': 'ждёт твоей проверки',
|
|
1493
|
+
'Instructions': 'Инструкции',
|
|
1494
|
+
'How large the CLAUDE.md of each project is': 'Насколько велик CLAUDE.md каждого проекта',
|
|
1495
|
+
'Stands': 'Стенды',
|
|
1496
|
+
'The git worktrees left behind by tasks, and their branches': 'Рабочие копии git, оставшиеся после задач, и их ветки',
|
|
1497
|
+
'GB': 'ГБ',
|
|
1498
|
+
'a Claude session is open in the folder': 'в папке открыта сессия Claude',
|
|
1499
|
+
'a process runs in the folder': 'в папке запущен процесс',
|
|
1500
|
+
'its task is in work': 'задача в работе',
|
|
1501
|
+
'the worktree is locked': 'рабочая копия заперта',
|
|
1502
|
+
'could not tell what is in it': 'не удалось понять, что в ней',
|
|
1503
|
+
'not committed': 'не закоммичено',
|
|
1504
|
+
'not in main': 'нет в main',
|
|
1505
|
+
'detached HEAD with commits of its own': 'без ветки, со своими коммитами',
|
|
1506
|
+
'in main as the same change': 'в main той же правкой',
|
|
1507
|
+
'in main by its title': 'в main по заголовку',
|
|
1508
|
+
'in main by its issue number': 'в main по номеру issue',
|
|
1509
|
+
'Remove': 'Убрать',
|
|
1510
|
+
'Show what is not in main': 'Показать, чего нет в main',
|
|
1511
|
+
'free on the disk': 'свободно на диске',
|
|
1512
|
+
'measured against': 'сверено с',
|
|
1513
|
+
'no main branch to measure against': 'нет ветки main для сверки',
|
|
1514
|
+
'Looking at the stands…': 'Смотрю стенды…',
|
|
1515
|
+
'Looked at': 'Проверено в',
|
|
1516
|
+
'can go': 'можно убрать',
|
|
1517
|
+
'No stands: the projects of the board have no git worktrees besides the main one.': 'Стендов нет: у проектов доски нет рабочих копий git, кроме основной.',
|
|
1518
|
+
'A stand is a git worktree of a task (.claude/worktrees/<KEY>) and its branch. «Can go» — nothing uncommitted and every commit is in main; the task you open checks each one again right before removing it, and never removes one with work not in main.': 'Стенд — рабочая копия git задачи (.claude/worktrees/<КЛЮЧ>) и её ветка. «Можно убрать» — нет незакоммиченного, и каждый коммит есть в main; задача, которую ты заведёшь, сверит каждый ещё раз прямо перед удалением и не тронет тот, где есть работа не из main.',
|
|
1519
|
+
'Untick all': 'Снять все',
|
|
1520
|
+
'Tick all that can go': 'Отметить все, что можно убрать',
|
|
1521
|
+
'Remove the ticked': 'Убрать отмеченные',
|
|
1522
|
+
'Can go': 'Можно убрать',
|
|
1523
|
+
'Work not in main': 'Есть несданное',
|
|
1524
|
+
'Not removed by the task: it asks you what to do with each.': 'Задача их не удаляет: про каждый она спросит тебя.',
|
|
1525
|
+
'Busy': 'Заняты',
|
|
1526
|
+
'Being looked at': 'Проверяются',
|
|
1527
|
+
'Nothing differs from main.': 'Ничем не отличается от main.',
|
|
1528
|
+
'Could not show the changes': 'Не удалось показать изменения',
|
|
1529
|
+
'A cleanup of these stands is already open': 'Уборка этих стендов уже заведена',
|
|
1530
|
+
'Could not look at the stands': 'Не удалось посмотреть стенды',
|
|
1490
1531
|
'Open the task «Shrink CLAUDE.md» and start Claude on it — only while nobody works in the folder of the project': 'Завести задачу «Ужать CLAUDE.md» и сразу запустить на ней Claude — только пока в папке проекта никто не работает',
|
|
1491
1532
|
'Claude works in the folder of this project — fix its CLAUDE.md once that work is over.': 'В папке этого проекта сейчас работает Claude — почини его CLAUDE.md, когда эта работа закончится.',
|
|
1492
1533
|
'Tasks in work': 'Задачи в работе',
|
|
@@ -9,6 +9,15 @@ export function fileState(f, norms) {
|
|
|
9
9
|
|
|
10
10
|
export const fixable = (main, norms) => ['heavy', 'long'].includes(fileState(main, norms));
|
|
11
11
|
|
|
12
|
+
const FIX_WORK = new Set(['prep', 'doing', 'ready', 'deploy']);
|
|
13
|
+
export function fixStage(fix) {
|
|
14
|
+
if (!fix) return 'none';
|
|
15
|
+
if (FIX_WORK.has(fix.status)) return 'work';
|
|
16
|
+
if (fix.status === 'review') return 'review';
|
|
17
|
+
if (fix.status === 'backlog' || fix.status === 'todo') return 'todo';
|
|
18
|
+
return 'none';
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
const RANK = { heavy: 0, long: 1, ok: 2 };
|
|
13
22
|
|
|
14
23
|
/** The projects split for the section: with their own CLAUDE.md (worst first, then the most loaded at the start), without
|
|
@@ -43,3 +52,23 @@ export function healthCounts(own, norms) {
|
|
|
43
52
|
}
|
|
44
53
|
return n;
|
|
45
54
|
}
|
|
55
|
+
|
|
56
|
+
const BY_SIZE = (a, b) => (b.size || 0) - (a.size || 0) || String(a.path).localeCompare(String(b.path));
|
|
57
|
+
/** The stands split for the section — what can go first, the biggest first: {free, unsent, busy, reading}. */
|
|
58
|
+
export function standGroups(stands) {
|
|
59
|
+
const out = { free: [], unsent: [], busy: [], reading: [] };
|
|
60
|
+
for (const s of stands || []) (out[s.state] || out.reading).push(s);
|
|
61
|
+
for (const k of Object.keys(out)) out[k].sort(BY_SIZE);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** How many stands and how much room: {n, size, freeN, freeSize}. */
|
|
65
|
+
export function standTotals(stands) {
|
|
66
|
+
let size = 0; let freeN = 0; let freeSize = 0;
|
|
67
|
+
for (const s of stands || []) {
|
|
68
|
+
size += s.size || 0;
|
|
69
|
+
if (s.state === 'free') { freeN += 1; freeSize += s.size || 0; }
|
|
70
|
+
}
|
|
71
|
+
return { n: (stands || []).length, size, freeN, freeSize };
|
|
72
|
+
}
|
|
73
|
+
/** The chosen folders that can still go: a stand chosen earlier that is no longer free drops out of the choice. */
|
|
74
|
+
export const chosenFree = (chosen, stands) => (stands || []).filter((s) => s.state === 'free' && chosen.has(s.path)).map((s) => s.path);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { $, HEALTH, api, esc, fmtNum, ic, state, tr } from './core.js';
|
|
2
|
+
import { keepFocus } from './now.js';
|
|
3
|
+
import { plural } from './settings.js';
|
|
4
|
+
import { styledAlert, styledConfirm } from './sidebar.js';
|
|
5
|
+
import { refusalLine } from './unsaved.js';
|
|
6
|
+
import { openDrawer } from './drawer.js';
|
|
7
|
+
import { SHORT_MONTHS, STATUS_LABELS, launchInClaude, taskPromptText } from './sse.js';
|
|
8
|
+
import { chosenFree, standGroups, standTotals } from './health-rules.js';
|
|
9
|
+
|
|
10
|
+
const POLL_MS = 2000; // while the walk runs
|
|
11
|
+
let last = null;
|
|
12
|
+
let drawnSig = '';
|
|
13
|
+
let poll = null;
|
|
14
|
+
const chosen = new Set(); // folders ticked by the person — kept through repaints
|
|
15
|
+
const opened = new Set(); // stands unfolded
|
|
16
|
+
const diffs = new Map(); // folder → the diff text, once asked for
|
|
17
|
+
let starting = false;
|
|
18
|
+
|
|
19
|
+
function gb(n) {
|
|
20
|
+
if (!n) return '—';
|
|
21
|
+
if (n >= 1024 ** 3) return `${fmtNum(n / 1024 ** 3, 1)} ${tr('GB')}`;
|
|
22
|
+
return `${fmtNum(Math.max(1, Math.round(n / 1024 ** 2)), 0)} ${tr('MB')}`; // a stand of a few files is no «0.0 GB»
|
|
23
|
+
}
|
|
24
|
+
const statusWord = (st) => STATUS_LABELS[st] || st;
|
|
25
|
+
function day(ms) {
|
|
26
|
+
if (!ms) return '';
|
|
27
|
+
const d = new Date(ms);
|
|
28
|
+
return `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]}${d.getFullYear() !== new Date().getFullYear() ? ` ${d.getFullYear()}` : ''}`;
|
|
29
|
+
}
|
|
30
|
+
const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
31
|
+
|
|
32
|
+
/** Why a stand is not free, in words. */
|
|
33
|
+
function whyWords(s) {
|
|
34
|
+
const n = () => (s.commits || []).filter((c) => !c.found).length;
|
|
35
|
+
return s.why.map((w) => ({
|
|
36
|
+
session: tr('a Claude session is open in the folder'),
|
|
37
|
+
process: tr('a process runs in the folder'),
|
|
38
|
+
task: `${tr('its task is in work')}${s.task ? ` (${statusWord(s.task.status)})` : ''}`,
|
|
39
|
+
locked: tr('the worktree is locked'),
|
|
40
|
+
unknown: tr('could not tell what is in it'),
|
|
41
|
+
dirty: `${tr('not committed')}: ${s.changes?.files ?? '?'} ${plural(s.changes?.files ?? 0, 'file', 'files')}`,
|
|
42
|
+
commits: `${tr('not in main')}: ${n()} ${plural(n(), 'commit', 'commits')}`,
|
|
43
|
+
detached: tr('detached HEAD with commits of its own'),
|
|
44
|
+
}[w] || w)).join(' · ');
|
|
45
|
+
}
|
|
46
|
+
const FOUND = {
|
|
47
|
+
patch: 'in main as the same change',
|
|
48
|
+
title: 'in main by its title',
|
|
49
|
+
issue: 'in main by its issue number',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function rowHTML(s) {
|
|
53
|
+
const name = s.path.split('/').pop();
|
|
54
|
+
const box = s.state === 'free'
|
|
55
|
+
? `<input type="checkbox" class="st-pick" data-path="${esc(s.path)}"${chosen.has(s.path) ? ' checked' : ''} aria-label="${esc(`${tr('Remove')} ${name}`)}">`
|
|
56
|
+
: '<span class="st-pick-gap"></span>';
|
|
57
|
+
const key = s.key ? `<a class="hl-fix-key" href="#" data-key="${esc(s.key)}">${esc(s.key)}</a>${s.task ? ` <span class="muted">${esc(statusWord(s.task.status))}</span>` : ''}` : '';
|
|
58
|
+
const head = `<span class="hl-main"><span class="hl-name" title="${esc(s.path)}">${esc(name)}</span>`
|
|
59
|
+
+ `<span class="hl-what"><span>${esc(s.project.name)}</span>${key ? `<span>${key}</span>` : ''}${s.branch && s.branch !== name ? `<span class="st-branch">${esc(s.branch)}</span>` : ''}`
|
|
60
|
+
+ `${s.state !== 'free' && s.state !== 'reading' ? `<span class="st-why">${esc(whyWords(s))}</span>` : ''}</span></span>`
|
|
61
|
+
+ `<span class="hl-start st-size">${esc(gb(s.size))}${s.changed_at ? ` · ${esc(day(s.changed_at))}` : ''}</span>`;
|
|
62
|
+
const commits = s.commits || [];
|
|
63
|
+
if (!commits.length && !(s.state === 'unsent' && s.dirty)) return `<div class="hl-row st-row">${box}<span class="st-chev-gap"></span>${head}</div>`;
|
|
64
|
+
const list = commits.map((c) => `<div class="hl-file"><span class="hl-file-path">${esc(c.sha.slice(0, 9))} ${esc(c.title)}</span>`
|
|
65
|
+
+ `<span class="hl-file-kind${c.found ? '' : ' hl-heavy'}">${esc(c.found ? tr(FOUND[c.found]) : tr('not in main'))}</span></div>`).join('');
|
|
66
|
+
const diff = s.state === 'unsent'
|
|
67
|
+
? (diffs.has(s.path) ? `<pre class="st-diff">${esc(diffs.get(s.path))}</pre>` : `<button type="button" class="btn-ghost st-diff-btn" data-path="${esc(s.path)}">${esc(tr('Show what is not in main'))}</button>`)
|
|
68
|
+
: '';
|
|
69
|
+
return `<details class="hl-proj st-proj" data-path="${esc(s.path)}"${opened.has(s.path) ? ' open' : ''}><summary class="hl-row st-row">${box}${ic('chevron', 13)}${head}</summary>`
|
|
70
|
+
+ `<div class="hl-files">${list}${diff}</div></details>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function groupHTML(head, list, hint = '') {
|
|
74
|
+
if (!list.length) return '';
|
|
75
|
+
return `<div class="hl-sec-h">${esc(tr(head))} <span class="hl-sec-n">${list.length}</span></div>${hint ? `<div class="muted hl-hint st-hint">${esc(tr(hint))}</div>` : ''}`
|
|
76
|
+
+ `<div class="panel hl-list">${list.map(rowHTML).join('')}</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function paint(d, tabs) {
|
|
80
|
+
const g = standGroups(d.stands);
|
|
81
|
+
const t = standTotals(d.stands);
|
|
82
|
+
const picked = chosenFree(chosen, d.stands);
|
|
83
|
+
const repos = d.repos.map((r) => `<div class="st-repo"><span class="hl-file-path">${esc(r.top)}</span><span class="muted">${r.free ? `${tr('free on the disk')}: ${esc(gb(r.free))}` : ''}${r.main ? ` · ${tr('measured against')} ${esc(r.main)}` : ` · ${tr('no main branch to measure against')}`}</span></div>`).join('');
|
|
84
|
+
const walk = d.running
|
|
85
|
+
? `${tr('Looking at the stands…')} ${d.done} / ${d.total}`
|
|
86
|
+
: `${tr('Looked at')} ${hhmm(d.at)}`;
|
|
87
|
+
const sum = t.n
|
|
88
|
+
? `${t.n} ${plural(t.n, 'stand', 'stands')} · ${gb(t.size)} · ${tr('can go')}: ${t.freeN} (${gb(t.freeSize)})`
|
|
89
|
+
: (d.running ? '' : tr('No stands: the projects of the board have no git worktrees besides the main one.'));
|
|
90
|
+
const head = '<div class="panel hl-norms">'
|
|
91
|
+
+ `<div class="hl-h">${tr('Stands')}<button type="button" class="btn-ghost st-again"${d.running ? ' disabled' : ''}>${tr('Check again')}</button></div>`
|
|
92
|
+
+ `<div class="muted hl-hint">${esc(tr('A stand is a git worktree of a task (.claude/worktrees/<KEY>) and its branch. «Can go» — nothing uncommitted and every commit is in main; the task you open checks each one again right before removing it, and never removes one with work not in main.'))}</div>`
|
|
93
|
+
+ `${repos}<div class="hl-sum muted">${esc(walk)}</div></div>`;
|
|
94
|
+
const bar = g.free.length
|
|
95
|
+
? `<div class="st-bar"><button type="button" class="btn-ghost st-all">${esc(picked.length === g.free.length ? tr('Untick all') : `${tr('Tick all that can go')} (${g.free.length})`)}</button>`
|
|
96
|
+
+ `<button type="button" class="btn-primary st-go"${picked.length && !starting ? '' : ' disabled'}>${esc(`${tr('Remove the ticked')} (${picked.length})`)}</button></div>`
|
|
97
|
+
: '';
|
|
98
|
+
const box = $('health');
|
|
99
|
+
keepFocus(box, () => {
|
|
100
|
+
box.innerHTML = `<div class="now-wrap hl-wrap">${tabs}${head}${sum ? `<div class="hl-sum muted">${esc(sum)}</div>` : ''}${bar}`
|
|
101
|
+
+ groupHTML('Can go', g.free)
|
|
102
|
+
+ groupHTML('Work not in main', g.unsent, 'Not removed by the task: it asks you what to do with each.')
|
|
103
|
+
+ groupHTML('Busy', g.busy)
|
|
104
|
+
+ groupHTML('Being looked at', g.reading) + '</div>';
|
|
105
|
+
});
|
|
106
|
+
box.querySelectorAll('details.st-proj').forEach((el) => {
|
|
107
|
+
el.ontoggle = () => { if (el.open) opened.add(el.dataset.path); else opened.delete(el.dataset.path); };
|
|
108
|
+
});
|
|
109
|
+
box.querySelectorAll('.st-pick').forEach((c) => {
|
|
110
|
+
c.onclick = (e) => e.stopPropagation(); // a tick in <summary> does not fold it
|
|
111
|
+
c.onchange = () => { if (c.checked) chosen.add(c.dataset.path); else chosen.delete(c.dataset.path); repaint(); };
|
|
112
|
+
});
|
|
113
|
+
box.querySelectorAll('a.hl-fix-key').forEach((a) => {
|
|
114
|
+
a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openDrawer(a.dataset.key); };
|
|
115
|
+
});
|
|
116
|
+
box.querySelectorAll('.st-diff-btn').forEach((b) => { b.onclick = () => showDiff(b.dataset.path, b); });
|
|
117
|
+
const again = box.querySelector('.st-again');
|
|
118
|
+
if (again) again.onclick = () => { again.disabled = true; renderStands(tabs, { force: true }); };
|
|
119
|
+
const all = box.querySelector('.st-all');
|
|
120
|
+
if (all) {
|
|
121
|
+
all.onclick = () => {
|
|
122
|
+
if (picked.length === g.free.length) chosen.clear(); else g.free.forEach((s) => chosen.add(s.path));
|
|
123
|
+
repaint();
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const go = box.querySelector('.st-go');
|
|
127
|
+
if (go) go.onclick = () => removeChosen(go, tabs);
|
|
128
|
+
}
|
|
129
|
+
let lastTabs = '';
|
|
130
|
+
const sigOf = (d) => JSON.stringify([d, [...chosen], [...diffs.keys()], starting]);
|
|
131
|
+
function repaint() {
|
|
132
|
+
if (!last || state.slug !== HEALTH) return;
|
|
133
|
+
drawnSig = sigOf(last);
|
|
134
|
+
paint(last, lastTabs);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function showDiff(path, btn) {
|
|
138
|
+
btn.disabled = true;
|
|
139
|
+
try {
|
|
140
|
+
const r = await api('GET', `/api/health/stands/diff?path=${encodeURIComponent(path)}`, undefined, { quiet: true });
|
|
141
|
+
diffs.set(path, r.text || tr('Nothing differs from main.'));
|
|
142
|
+
opened.add(path);
|
|
143
|
+
repaint();
|
|
144
|
+
} catch (e) {
|
|
145
|
+
btn.disabled = false;
|
|
146
|
+
styledAlert(refusalLine('Could not show the changes', e, tr));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function removeChosen(btn, tabs) {
|
|
151
|
+
const paths = chosenFree(chosen, last?.stands);
|
|
152
|
+
if (!paths.length || starting) return;
|
|
153
|
+
starting = true;
|
|
154
|
+
btn.disabled = true;
|
|
155
|
+
try {
|
|
156
|
+
let r;
|
|
157
|
+
try { r = await api('POST', '/api/health/stands/task', { paths }, { quiet: true }); } catch (e) {
|
|
158
|
+
const b = e.body || {};
|
|
159
|
+
if (b.reason === 'cleanup-open' && b.key) {
|
|
160
|
+
styledConfirm(`${tr('A cleanup of these stands is already open')}: ${b.key}.`, { okLabel: `${tr('Open')} ${b.key}` }).then((ok) => { if (ok) openDrawer(b.key); });
|
|
161
|
+
} else styledAlert(refusalLine('Could not open the task', e, tr));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
chosen.clear();
|
|
165
|
+
for (const t of r.tasks || []) await launchInClaude({ text: taskPromptText(t.key), project: t.project, keys: [t.key], btn });
|
|
166
|
+
} finally {
|
|
167
|
+
starting = false;
|
|
168
|
+
renderStands(tabs).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let seq = 0;
|
|
173
|
+
let askedAt = 0;
|
|
174
|
+
const REASK_MS = 30000; // a finished picture changes only with a walk: the events of the board do not ask for it again
|
|
175
|
+
/** Draw the stands; `tabs` — the switch of the sections, drawn on top. Asks again every POLL_MS while the walk runs. */
|
|
176
|
+
export async function renderStands(tabs, { force = false } = {}) {
|
|
177
|
+
lastTabs = tabs;
|
|
178
|
+
if (!force && last && !last.running && Date.now() - askedAt < REASK_MS) {
|
|
179
|
+
if (!$('health').querySelector('.st-again')) { drawnSig = ''; repaint(); }
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
clearTimeout(poll);
|
|
183
|
+
const my = ++seq;
|
|
184
|
+
askedAt = Date.now();
|
|
185
|
+
if (!last) $('health').innerHTML = `<div class="now-wrap hl-wrap">${tabs}<div class="muted">${tr('Looking at the stands…')}</div></div>`;
|
|
186
|
+
let d;
|
|
187
|
+
try { d = await api('GET', `/api/health/stands${force ? '?refresh=1' : ''}`, undefined, { quiet: true }); } catch (e) {
|
|
188
|
+
if (my !== seq || state.slug !== HEALTH) return;
|
|
189
|
+
if (!last) $('health').innerHTML = `<div class="now-wrap hl-wrap">${tabs}<div class="muted">${esc(refusalLine('Could not look at the stands', e, tr))}</div></div>`;
|
|
190
|
+
else { askedAt = 0; const b = $('health').querySelector('.st-again'); if (b) b.disabled = false; } // a failed «Check again» can be pressed again
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (my !== seq || state.slug !== HEALTH) return;
|
|
194
|
+
if (last && last.at !== d.at) diffs.clear(); // a new walk: what was not in main may have landed since
|
|
195
|
+
last = d;
|
|
196
|
+
const sig = sigOf(d);
|
|
197
|
+
if (sig !== drawnSig || !$('health').querySelector('.st-bar, .st-again')) { drawnSig = sig; paint(d, tabs); }
|
|
198
|
+
if (d.running) poll = setTimeout(() => { if (state.slug === HEALTH && $('health').querySelector('.st-again')) renderStands(tabs).catch(() => {}); }, POLL_MS);
|
|
199
|
+
}
|
|
200
|
+
/** The section was left: no more asking. */
|
|
201
|
+
export function stopStands() { clearTimeout(poll); seq += 1; }
|
package/public/js/health.js
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
|
|
2
2
|
import { $, HEALTH, LANG, api, esc, ic, state, tr } from './core.js';
|
|
3
|
+
import { liveBadge } from './board.js';
|
|
3
4
|
import { keepFocus } from './now.js';
|
|
4
5
|
import { plural } from './settings.js';
|
|
5
6
|
import { selectProject, styledAlert, styledConfirm } from './sidebar.js';
|
|
6
7
|
import { refusalLine } from './unsaved.js';
|
|
7
8
|
import { openDrawer } from './drawer.js';
|
|
8
|
-
import { launchInClaude, taskPromptText } from './sse.js';
|
|
9
|
-
import { fileState, fixable, healthCounts, healthGroups, startTotal } from './health-rules.js';
|
|
9
|
+
import { STATUS_LABELS, launchInClaude, taskPromptText } from './sse.js';
|
|
10
|
+
import { fileState, fixStage, fixable, healthCounts, healthGroups, startTotal } from './health-rules.js';
|
|
11
|
+
import { renderStands, stopStands } from './health-stands.js';
|
|
12
|
+
import { localStore } from './store.js';
|
|
13
|
+
|
|
14
|
+
const TAB_KEY = 'kb.ui.healthTab';
|
|
15
|
+
const TABS = ['files', 'stands'];
|
|
16
|
+
let tab = 'files';
|
|
17
|
+
try { const v = localStore.getItem(TAB_KEY); if (TABS.includes(v)) tab = v; } catch { /* private mode: the first one */ }
|
|
18
|
+
function tabsHTML() {
|
|
19
|
+
const b = (v, label, title) => `<button type="button" class="dr-btn${tab === v ? ' active' : ''}" data-hl-tab="${v}" role="tab" aria-selected="${tab === v}" title="${esc(tr(title))}">${esc(tr(label))}</button>`;
|
|
20
|
+
return `<div class="now-bar hl-tabs"><div class="dash-range" role="tablist">${b('files', 'Instructions', 'How large the CLAUDE.md of each project is')}`
|
|
21
|
+
+ `${b('stands', 'Stands', 'The git worktrees left behind by tasks, and their branches')}</div></div>`;
|
|
22
|
+
}
|
|
23
|
+
// one listener on the section for the switch: both sections draw it anew on every paint
|
|
24
|
+
let tabsWired = false;
|
|
25
|
+
function wireTabs() {
|
|
26
|
+
if (tabsWired) return;
|
|
27
|
+
tabsWired = true;
|
|
28
|
+
$('health').addEventListener('click', (e) => {
|
|
29
|
+
const b = e.target.closest?.('[data-hl-tab]');
|
|
30
|
+
if (!b || b.dataset.hlTab === tab) return;
|
|
31
|
+
tab = b.dataset.hlTab;
|
|
32
|
+
try { localStore.setItem(TAB_KEY, tab); } catch { /* the choice lives until reload */ }
|
|
33
|
+
drawnSig = '';
|
|
34
|
+
if (tab !== 'stands') stopStands();
|
|
35
|
+
renderHealth().catch(() => {});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
10
38
|
|
|
11
39
|
const FRESH_MS = 30000;
|
|
12
40
|
const DOCS_URL = 'https://code.claude.com/docs/en/memory';
|
|
13
41
|
let last = null; // { at, d }
|
|
42
|
+
let fixes = {};
|
|
14
43
|
let drawnSig = '';
|
|
15
44
|
const opened = new Set(); // the projects whose files are unfolded — kept through repaints
|
|
16
45
|
|
|
@@ -51,9 +80,8 @@ function projectHTML(p, d) {
|
|
|
51
80
|
: `<span class="hl-size muted">${esc(tr('no CLAUDE.md of its own'))}</span>`;
|
|
52
81
|
const start = `<span class="hl-start${total.over ? ' hl-heavy' : ''}" title="${esc(tr('Everything a session reads when it starts in this folder, with your own files'))}">`
|
|
53
82
|
+ `${esc(tr('at the start'))}: ${esc(num(total.chars))} ${esc(plural(total.chars, 'character', 'characters'))}</span>`;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
: '';
|
|
83
|
+
const button = `<button type="button" class="btn-ghost hl-fix" data-slug="${esc(p.slug)}" title="${esc(tr('Open the task «Shrink CLAUDE.md» and start Claude on it — only while nobody works in the folder of the project'))}">${esc(tr('Fix'))}</button>`;
|
|
84
|
+
const fix = fixHTML(fixes[p.slug], p.main && fixable(p.main, norms) ? button : '');
|
|
57
85
|
const files = [...p.files, ...p.nested];
|
|
58
86
|
const body = files.length
|
|
59
87
|
? files.map((f) => fileHTML(f, norms)).join('')
|
|
@@ -64,6 +92,18 @@ function projectHTML(p, d) {
|
|
|
64
92
|
+ `<div class="hl-files">${body}</div></details>`;
|
|
65
93
|
}
|
|
66
94
|
|
|
95
|
+
const statusWord = (st) => STATUS_LABELS[st] || st;
|
|
96
|
+
function fixHTML(fx, button) {
|
|
97
|
+
const stage = fixStage(fx);
|
|
98
|
+
if (stage === 'none') return button;
|
|
99
|
+
const key = `<a class="hl-fix-key" href="#" data-key="${esc(fx.key)}" title="${esc(tr('Open the task of the fix'))}">${esc(fx.key)}</a>`;
|
|
100
|
+
let what;
|
|
101
|
+
if (stage === 'work') what = liveBadge({ id: fx.id, status: fx.status }) || `<span class="muted">${esc(statusWord(fx.status))} · ${esc(tr('Claude is not running'))}</span>`;
|
|
102
|
+
else if (stage === 'review') what = `<span class="muted">${esc(tr('waits for your review'))}</span>`;
|
|
103
|
+
else what = `<span class="muted">${esc(statusWord(fx.status))}</span>`;
|
|
104
|
+
return `<span class="hl-fixing">${key}${what}${stage === 'todo' ? button : ''}</span>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
67
107
|
function normsHTML(d) {
|
|
68
108
|
const n = d.norms;
|
|
69
109
|
const row = (lead, why) => `<div class="hl-norm"><span class="hl-norm-n">${lead}</span><span class="muted">${why}</span></div>`;
|
|
@@ -100,7 +140,7 @@ function paint(d) {
|
|
|
100
140
|
: '';
|
|
101
141
|
const box = $('health');
|
|
102
142
|
keepFocus(box, () => {
|
|
103
|
-
box.innerHTML = `<div class="now-wrap hl-wrap">${normsHTML(d)}<div class="hl-sum muted">${esc(sum)}</div>${mine}`
|
|
143
|
+
box.innerHTML = `<div class="now-wrap hl-wrap">${tabsHTML()}${normsHTML(d)}<div class="hl-sum muted">${esc(sum)}</div>${mine}`
|
|
104
144
|
+ groupHTML(tr('With a CLAUDE.md of its own'), own, d)
|
|
105
145
|
+ groupHTML(tr('Without one'), bare, d, { fold: true })
|
|
106
146
|
+ groupHTML(tr('No folder on this computer'), away, d, { fold: true }) + '</div>';
|
|
@@ -115,6 +155,9 @@ function paint(d) {
|
|
|
115
155
|
a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); selectProject(a.dataset.slug); };
|
|
116
156
|
});
|
|
117
157
|
box.querySelector('.hl-again').onclick = (e) => { e.currentTarget.disabled = true; renderHealth({ force: true }); };
|
|
158
|
+
box.querySelectorAll('a.hl-fix-key').forEach((a) => {
|
|
159
|
+
a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openDrawer(a.dataset.key); };
|
|
160
|
+
});
|
|
118
161
|
box.querySelectorAll('.hl-fix').forEach((b) => {
|
|
119
162
|
b.onclick = (e) => { e.preventDefault(); e.stopPropagation(); fixProject(b.dataset.slug, b); }; // a click in <summary> folds it
|
|
120
163
|
});
|
|
@@ -148,30 +191,46 @@ async function fixProject(slug, btn) {
|
|
|
148
191
|
fixing.delete(slug);
|
|
149
192
|
btn.disabled = false;
|
|
150
193
|
}
|
|
194
|
+
renderHealth().catch(() => {});
|
|
151
195
|
}
|
|
152
196
|
|
|
153
197
|
let seq = 0;
|
|
154
198
|
/** Draw the section; the folders are walked again only when the last answer is older than FRESH_MS, or on `force`. */
|
|
199
|
+
export function repaintHealth() {
|
|
200
|
+
if (state.slug !== HEALTH || tab !== 'files' || !last || !$('health').firstChild) return;
|
|
201
|
+
const sig = sigOf(last.d);
|
|
202
|
+
if (sig !== drawnSig) { drawnSig = sig; paint(last.d); }
|
|
203
|
+
}
|
|
204
|
+
// the badge itself, not only state.live: a question of its Claude (hasAsk) outranks the state of the session
|
|
205
|
+
const sigOf = (d) => JSON.stringify([d, fixes, Object.values(fixes).map((f) => liveBadge({ id: f.id, status: f.status }))]);
|
|
206
|
+
async function loadFixes() {
|
|
207
|
+
try { fixes = (await api('GET', '/api/health/fixes', undefined, { quiet: true })).fixes || {}; } catch { /* the last ones stay */ }
|
|
208
|
+
}
|
|
155
209
|
export async function renderHealth({ force = false } = {}) {
|
|
210
|
+
wireTabs();
|
|
211
|
+
if (tab === 'stands') return renderStands(tabsHTML());
|
|
156
212
|
if (last && !force && Date.now() - last.at < FRESH_MS) {
|
|
213
|
+
await loadFixes();
|
|
214
|
+
if (state.slug !== HEALTH || tab !== 'files') return;
|
|
157
215
|
if (!$('health').firstChild) { drawnSig = ''; }
|
|
158
|
-
const sig =
|
|
216
|
+
const sig = sigOf(last.d);
|
|
159
217
|
if (sig !== drawnSig) { drawnSig = sig; paint(last.d); }
|
|
160
218
|
return;
|
|
161
219
|
}
|
|
162
220
|
const my = ++seq;
|
|
163
|
-
if (!last) $('health').innerHTML = `<div class="now-wrap"
|
|
221
|
+
if (!last || !$('health').querySelector('.hl-again')) $('health').innerHTML = `<div class="now-wrap hl-wrap">${tabsHTML()}<div class="muted">${tr('Measuring the instructions of the projects…')}</div></div>`;
|
|
164
222
|
let d;
|
|
165
|
-
try { d = await api('GET', '/api/health', undefined, { quiet: true }); }
|
|
223
|
+
try { [d] = await Promise.all([api('GET', '/api/health', undefined, { quiet: true }), loadFixes()]); }
|
|
166
224
|
catch {
|
|
167
225
|
if (my !== seq || state.slug !== HEALTH) return;
|
|
168
|
-
if (
|
|
226
|
+
if (tab !== 'files') return;
|
|
227
|
+
if (!last) $('health').innerHTML = `<div class="now-wrap hl-wrap">${tabsHTML()}<div class="muted">${tr('Could not measure the instructions of the projects.')}</div></div>`;
|
|
169
228
|
else { const b = $('health').querySelector('.hl-again'); if (b) b.disabled = false; }
|
|
170
229
|
return; // a failed reload keeps the last picture
|
|
171
230
|
}
|
|
172
231
|
if (my !== seq) return;
|
|
173
232
|
last = { at: Date.now(), d };
|
|
174
|
-
if (state.slug !== HEALTH) return; // the person moved on; the answer waits for the next opening
|
|
175
|
-
drawnSig =
|
|
233
|
+
if (state.slug !== HEALTH || tab !== 'files') return; // the person moved on; the answer waits for the next opening
|
|
234
|
+
drawnSig = sigOf(d);
|
|
176
235
|
paint(d);
|
|
177
236
|
}
|
package/public/js/settings.js
CHANGED
|
@@ -1318,6 +1318,7 @@ const PLURAL_RU = {
|
|
|
1318
1318
|
question: ['вопрос', 'вопроса', 'вопросов'],
|
|
1319
1319
|
tool: ['инструмент', 'инструмента', 'инструментов'],
|
|
1320
1320
|
'background task': ['фоновую работу', 'фоновые работы', 'фоновых работ'],
|
|
1321
|
+
stand: ['стенд', 'стенда', 'стендов'],
|
|
1321
1322
|
};
|
|
1322
1323
|
export function plural(n, one, many) {
|
|
1323
1324
|
const forms = LANG === 'ru' && PLURAL_RU[one];
|
package/public/js/sse.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
|
|
2
|
+
import { repaintHealth } from './health.js';
|
|
2
3
|
import { applyLive, patchCard, renderBoard } from './board.js';
|
|
3
4
|
import { $, ALL, ALL_STATUSES, AUTO, HEALTH, CALENDAR, CHAOS, DASH, DONE, HORIZON, LANG, NOW, REVIEW, SETTINGS, api, esc, fmtNum, ic, readyInUse, retryUnsaved, seg, setGhSyncOn, state, tr } from './core.js';
|
|
4
5
|
import { applyChecklist, closeDrawer, commentArrived, openDrawer, openTerminalOf, peekTerminal, refreshWork, renderComments, renderIssueRow, syncOpenDrawerStatus } from './drawer.js';
|
|
@@ -70,7 +71,7 @@ async function loadLive() {
|
|
|
70
71
|
const fresh = applyTerminalQuestions(live.questions);
|
|
71
72
|
if (liveLoaded) maybeQuestionAlert(fresh, maybeWaitingAlert(changes));
|
|
72
73
|
liveLoaded = true;
|
|
73
|
-
markNowEntry(); repaintReview();
|
|
74
|
+
markNowEntry(); repaintReview(); repaintHealth();
|
|
74
75
|
} catch { }
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -347,12 +348,12 @@ function streamMessage(data) {
|
|
|
347
348
|
if (ev.type === 'sync.status') { refreshSync(); return; }
|
|
348
349
|
if (ev.type === 'session.chat') { chatEvent(ev.data?.session); return; }
|
|
349
350
|
if (ev.type === 'task.work') { const open = state.drawerKey && (ev.data?.keys || []).includes(state.drawerKey); if (open) refreshUsage(state.drawerKey); if (open) refreshWork(state.drawerKey); return; }
|
|
350
|
-
if (ev.type === 'task.live') { liveEvents++; state.liveProjects = ev.data?.projects || []; maybeQuestionAlert(applyTerminalQuestions(ev.data?.questions), maybeWaitingAlert(applyLive(ev.data?.tasks))); markNowEntry(); peekTerminal(); if (state.slug === DASH || state.slug === NOW || state.slug === REVIEW) loadTasks().catch(() => {}); return; }
|
|
351
|
+
if (ev.type === 'task.live') { liveEvents++; state.liveProjects = ev.data?.projects || []; maybeQuestionAlert(applyTerminalQuestions(ev.data?.questions), maybeWaitingAlert(applyLive(ev.data?.tasks))); markNowEntry(); peekTerminal(); if (state.slug === DASH || state.slug === NOW || state.slug === REVIEW) loadTasks().catch(() => {}); repaintHealth(); return; }
|
|
351
352
|
if (ev.type === 'task.checklist') { applyChecklist(ev.data?.key, ev.data?.checklist); if (state.slug === NOW && !ev.data?.text_only) loadTasks().catch(() => {}); return; }
|
|
352
353
|
if (ev.type === 'limits.updated') { applyLimits(ev.data); return; }
|
|
353
354
|
if (ev.type === 'share.updated') { applyShare(ev.data); return; }
|
|
354
355
|
if (ev.type === 'automation.updated') { applyAutomations(ev.data); return; }
|
|
355
|
-
if (ev.type === 'ask.created' || ev.type === 'ask.removed') { applyAskEvent(ev); return; }
|
|
356
|
+
if (ev.type === 'ask.created' || ev.type === 'ask.removed') { applyAskEvent(ev); repaintHealth(); return; }
|
|
356
357
|
if (ev.type === 'task.comment') commentArrived(ev.data.key, ev.data.comment, ev.data.comments_n);
|
|
357
358
|
maybeReviewSound(ev);
|
|
358
359
|
maybeReviewNotify(ev);
|
package/public/style.css
CHANGED
|
@@ -2783,6 +2783,22 @@ a.hl-name:hover { color: var(--accent); }
|
|
|
2783
2783
|
.hl-start { flex: none; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
2784
2784
|
.hl-start.hl-heavy { color: var(--accent); }
|
|
2785
2785
|
.hl-fix { flex: none; font-size: 12px; padding: 3px 10px; }
|
|
2786
|
+
.hl-fixing { flex: none; display: inline-flex; align-items: center; gap: 8px; font-size: 12px; white-space: nowrap; }
|
|
2787
|
+
.hl-fix-key { color: var(--text); font-variant-numeric: tabular-nums; }
|
|
2788
|
+
.hl-fixing .card-live { font-size: 12px; }
|
|
2789
|
+
.hl-tabs { margin-bottom: 2px; }
|
|
2790
|
+
.st-row { align-items: center; }
|
|
2791
|
+
.st-pick { flex: none; width: 15px; height: 15px; margin: 0; accent-color: var(--accent); cursor: pointer; }
|
|
2792
|
+
.st-pick-gap { flex: none; width: 15px; }
|
|
2793
|
+
.st-chev-gap { flex: none; width: 13px; }
|
|
2794
|
+
.st-branch { font-family: ui-monospace, monospace; font-size: 11.5px; overflow-wrap: anywhere; }
|
|
2795
|
+
.st-why { color: var(--text); }
|
|
2796
|
+
.st-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
2797
|
+
.st-bar .btn-ghost, .st-bar .btn-primary { font-size: 12.5px; }
|
|
2798
|
+
.st-repo { display: flex; flex-wrap: wrap; gap: 2px 10px; font-size: 12px; }
|
|
2799
|
+
.st-hint { padding: 0 2px; }
|
|
2800
|
+
.st-diff-btn { align-self: flex-start; font-size: 12px; padding: 3px 10px; }
|
|
2801
|
+
.st-diff { margin: 0; max-height: 360px; overflow: auto; font: 11.5px/1.45 ui-monospace, monospace; background: var(--input-bg); border: 1px solid var(--border-soft); border-radius: 6px; padding: 8px 10px; white-space: pre; }
|
|
2786
2802
|
.hl-files { display: flex; flex-direction: column; gap: 6px; padding: 0 0 12px 23px; }
|
|
2787
2803
|
.hl-file { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1px 12px; font-size: 12px; line-height: 1.4; }
|
|
2788
2804
|
.hl-file-path { font-family: ui-monospace, monospace; font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -2794,7 +2810,9 @@ a.hl-name:hover { color: var(--accent); }
|
|
|
2794
2810
|
.hl-norm { grid-template-columns: 1fr; }
|
|
2795
2811
|
.hl-row { align-items: flex-start; }
|
|
2796
2812
|
.hl-start { display: none; }
|
|
2813
|
+
.hl-start.st-size { display: inline; align-self: flex-start; }
|
|
2797
2814
|
.hl-state { white-space: normal; }
|
|
2815
|
+
.hl-fixing { flex: 0 1 auto; flex-wrap: wrap; justify-content: flex-end; row-gap: 4px; max-width: 50%; white-space: normal; text-align: right; }
|
|
2798
2816
|
.hl-file { grid-template-columns: 1fr; }
|
|
2799
2817
|
.hl-file-size { grid-column: 1; grid-row: auto; white-space: normal; }
|
|
2800
2818
|
}
|
package/src/auto-health.js
CHANGED
|
@@ -68,6 +68,15 @@ export async function fixHolds(project, registry = readRegistry) {
|
|
|
68
68
|
return { sessions: claudesAt(folder, entries), keys };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** The open task «Shrink CLAUDE.md» of each project, the newest: {slug: {id, key, status}}. Read on every look — no walk. */
|
|
72
|
+
export function openFixes() {
|
|
73
|
+
const rows = db.prepare(`SELECT p.slug, t.id, p.prefix || '-' || t.task_no AS key, t.status FROM tasks t JOIN projects p ON p.id = t.project_id
|
|
74
|
+
WHERE p.archived = 0 AND t.title IN (?, ?) AND t.status NOT IN ('done', 'cancelled') ORDER BY t.id`).all(TITLES.ru, TITLES.en);
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const r of rows) out[r.slug] = { id: r.id, key: r.key, status: r.status }; // the newest wins
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
const fixing = new Map(); // slug → the press in flight
|
|
72
81
|
/** The task of «Fix» for a project: one still open of the same title (backlog → To do), else a new one in To do.
|
|
73
82
|
{key, created} or {code, error, reason, …} of a refusal. */
|
package/src/phantom.js
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { claudeCli, claudeRunnable, EFFORT_VAR, effortProblem, keysProblem, onPath } from './launch.js';
|
|
8
8
|
import { SESSION_ID_RE } from './claude-sessions.js';
|
|
9
9
|
import { db, logError } from './db.js';
|
|
10
|
-
import { claudeConfigFile, DATA_DIR, tmuxSocket } from './config.js';
|
|
10
|
+
import { claudeConfigFile, DATA_DIR, PORT, tmuxSocket } from './config.js';
|
|
11
11
|
import { readRegistry, stateOf, screenState } from './claude-live.js';
|
|
12
12
|
import { waitsOwnWork } from './own-wait.js';
|
|
13
13
|
import { clearKeys, inField, promptBox, tallBox, undim } from './prompt-box.js';
|
|
@@ -511,6 +511,36 @@ async function captureScreens(sids) {
|
|
|
511
511
|
return new Map(sids.map((sid, i) => [sid, parts[i]]).filter(([, screen]) => screen !== undefined));
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
const portOfEnv = (env) => {
|
|
515
|
+
const m = /^KB_URL=(.*)$/m.exec(env || '');
|
|
516
|
+
if (!m) return null;
|
|
517
|
+
try { const u = new URL(m[1].trim()); return Number(u.port || (u.protocol === 'https:' ? 443 : 80)); } catch { return null; }
|
|
518
|
+
};
|
|
519
|
+
/** The port of the board that started each session (KB_URL in the session's environment): Map sid → port, or null with
|
|
520
|
+
no KB_URL; a session tmux did not answer about (closed in between) is missing. One call to tmux, as captureScreens. */
|
|
521
|
+
export async function boardPorts(sids) {
|
|
522
|
+
const out = new Map();
|
|
523
|
+
if (!sids.length || !tmuxBin()) return out;
|
|
524
|
+
const cut = `kb-cut-${randomUUID()}`;
|
|
525
|
+
const r = await tmux(...sids.flatMap((sid) => [['display-message', '-p', cut], ['show-environment', '-t', `=${sessionName(sid)}`]]));
|
|
526
|
+
if (!r.err) {
|
|
527
|
+
const parts = r.out.split(`${cut}\n`).slice(1);
|
|
528
|
+
sids.forEach((sid, i) => { if (parts[i] !== undefined) out.set(sid, portOfEnv(parts[i])); });
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
// a session that closed in between fails the whole chain: the rest are asked one by one
|
|
532
|
+
for (const sid of sids) {
|
|
533
|
+
const one = await tmux(['show-environment', '-t', `=${sessionName(sid)}`]);
|
|
534
|
+
if (!one.err) out.set(sid, portOfEnv(one.out));
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
/** The sessions of `sids` this very board started (its port in their KB_URL). */
|
|
539
|
+
export async function startedHere(sids) {
|
|
540
|
+
const ports = await boardPorts(sids);
|
|
541
|
+
return sids.filter((sid) => ports.get(sid) === PORT);
|
|
542
|
+
}
|
|
543
|
+
|
|
514
544
|
let sweeping = Promise.resolve();
|
|
515
545
|
/** Close the board's terminals none of whose tasks is in play anymore. Returns the ids of the sessions closed. */
|
|
516
546
|
export function closeFinishedTerminals() {
|
|
@@ -534,7 +564,10 @@ async function sweepOnce() {
|
|
|
534
564
|
}
|
|
535
565
|
return seen.get(sid);
|
|
536
566
|
};
|
|
537
|
-
const
|
|
567
|
+
const candidates = (await socketPanes()).filter((p) => ours.has(p.sid) && !tasksOf(p.sid).work);
|
|
568
|
+
if (!candidates.length) return [];
|
|
569
|
+
const here = new Set(await startedHere(candidates.map((p) => p.sid)));
|
|
570
|
+
const panes = candidates.filter((p) => here.has(p.sid));
|
|
538
571
|
if (!panes.length) return [];
|
|
539
572
|
const claudes = await registryByPid();
|
|
540
573
|
const finished = [];
|
|
@@ -668,15 +701,12 @@ export async function boardLook() {
|
|
|
668
701
|
/** The board's own live terminals with a question open on their screen: [sid]. */
|
|
669
702
|
export async function boardQuestions() { return (await boardLook()).questions; }
|
|
670
703
|
|
|
671
|
-
/** Sessions this board started before it kept board_sessions: one on its socket that is tied to its tasks
|
|
672
|
-
is its own. Only at the start of the board itself, never on a request — a test's database must never
|
|
673
|
-
claim a session of the live socket. */
|
|
674
704
|
export async function adoptBoardTerminals() {
|
|
675
705
|
if (!tmuxBin()) return 0;
|
|
676
706
|
const tied = db.prepare('SELECT 1 FROM task_sessions WHERE session_id = ? LIMIT 1');
|
|
677
707
|
const add = db.prepare('INSERT OR IGNORE INTO board_sessions (session_id) VALUES (?)');
|
|
678
708
|
let n = 0;
|
|
679
|
-
for (const
|
|
709
|
+
for (const sid of await startedHere((await socketPanes()).map((p) => p.sid).filter((sid) => tied.get(sid)))) n += add.run(sid).changes;
|
|
680
710
|
return n;
|
|
681
711
|
}
|
|
682
712
|
|
package/src/routes/health.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { db } from '../db.js';
|
|
1
|
+
import { db, uiLang } from '../db.js';
|
|
2
|
+
import { PORT } from '../config.js';
|
|
3
|
+
import { scalarQuery } from '../query-params.js';
|
|
4
|
+
import { cleanupTasks, knownStand, standDiff, standsView } from '../stands.js';
|
|
2
5
|
import { healthView } from '../health.js';
|
|
3
|
-
import { healthFixTask } from '../auto-health.js';
|
|
6
|
+
import { healthFixTask, openFixes } from '../auto-health.js';
|
|
4
7
|
|
|
5
8
|
let flight = null; // a second request while the folders are walked waits for the same walk
|
|
6
9
|
export default async function healthRoutes(app) {
|
|
@@ -11,6 +14,27 @@ export default async function healthRoutes(app) {
|
|
|
11
14
|
}
|
|
12
15
|
return flight;
|
|
13
16
|
});
|
|
17
|
+
app.get('/api/health/stands', (req, reply) => {
|
|
18
|
+
const q = scalarQuery(req.query, ['refresh']);
|
|
19
|
+
if (q.error) return reply.code(400).send({ error: q.error });
|
|
20
|
+
return standsView({ force: q.values.refresh === '1' });
|
|
21
|
+
});
|
|
22
|
+
// what of a stand is not in main — only a folder of the last walk, never a path the request makes up
|
|
23
|
+
app.get('/api/health/stands/diff', async (req, reply) => {
|
|
24
|
+
const q = scalarQuery(req.query, ['path']);
|
|
25
|
+
if (q.error) return reply.code(400).send({ error: q.error });
|
|
26
|
+
if (!q.values.path || !knownStand(q.values.path)) return reply.code(404).send({ error: 'no such stand in the last look', field: 'path' });
|
|
27
|
+
return standDiff(q.values.path);
|
|
28
|
+
});
|
|
29
|
+
// the task of the cleanup — one per repository; the page then starts each as «Fix» does
|
|
30
|
+
app.post('/api/health/stands/task', async (req, reply) => {
|
|
31
|
+
const paths = req.body?.paths;
|
|
32
|
+
if (!Array.isArray(paths) || !paths.length || paths.some((p) => typeof p !== 'string')) return reply.code(400).send({ error: 'paths: a list of folders', field: 'paths' });
|
|
33
|
+
const r = await cleanupTasks(app, paths, uiLang() === 'en' ? 'en' : 'ru', { host: `127.0.0.1:${PORT}` });
|
|
34
|
+
if (r.code) { const { code, ...body } = r; return reply.code(code).send(body); }
|
|
35
|
+
return r;
|
|
36
|
+
});
|
|
37
|
+
app.get('/api/health/fixes', () => ({ fixes: openFixes() }));
|
|
14
38
|
app.post('/api/health/fix', async (req, reply) => {
|
|
15
39
|
const slug = req.body?.project;
|
|
16
40
|
if (typeof slug !== 'string' || !slug) return reply.code(400).send({ error: 'project: a slug', field: 'project' });
|
package/src/routes/system.js
CHANGED
|
@@ -13,7 +13,7 @@ import { listBackups, backupNow, backupPath, backupState, ATTACH_MIRROR } from '
|
|
|
13
13
|
import { inspectBackup, startPreview, stopPreview, currentPreview, UPLOAD_PREFIX } from '../restore.js';
|
|
14
14
|
import { langOverride, ghOwnerEnv, ghRepoEnv, launchDryRun, PORT } from '../config.js';
|
|
15
15
|
import { claudeWord, launchOptions, launchProblem, launchSpec, runLaunch } from '../launch.js';
|
|
16
|
-
import { TMUX_COMMAND_MAX, boardProblem, claudeAtPrompt, folderTrusted, lookedLately, markTaskless, phantomAvailable, phantomPane, phantomSpec, phantomStop, runPhantom, taskTerminal, tmuxBin, tmuxCommandBytes, tmuxRefusal } from '../phantom.js';
|
|
16
|
+
import { TMUX_COMMAND_MAX, boardProblem, claudeAtPrompt, folderTrusted, lookedLately, markTaskless, phantomAvailable, phantomPane, phantomSpec, phantomStop, runPhantom, startedHere, taskTerminal, tmuxBin, tmuxCommandBytes, tmuxRefusal } from '../phantom.js';
|
|
17
17
|
import { ADOPT_TOKEN, freshTerminalSessions, isBoardSession, markBoardSession, markResumed, prepHolder, tieLaunched } from '../claude-sessions.js';
|
|
18
18
|
import { CHECK_LABEL, OPEN_CHECK, doneRules, isCheck, leadCheck } from '../review-check.js';
|
|
19
19
|
import { liveTick, readRegistry } from '../claude-live.js';
|
|
@@ -601,7 +601,7 @@ export default async function systemRoutes(app) {
|
|
|
601
601
|
let term = await taskTerminal(t.id);
|
|
602
602
|
// returned the task, the last round) makes room, or the fix waited for its terminal for up to six hours. Only the fix of
|
|
603
603
|
// an automation asks for it (`adopt` would not do: a resume sends that too), and never over a terminal being looked at
|
|
604
|
-
for (let i = 0; i < ROOM_TRIES && b.makeRoom === ADOPT_TOKEN && term?.session && !term.unknown && !term.dead && await restsAtPrompt(term.session); i += 1) {
|
|
604
|
+
for (let i = 0; i < ROOM_TRIES && b.makeRoom === ADOPT_TOKEN && term?.session && !term.unknown && !term.dead && (await startedHere([term.session])).length && await restsAtPrompt(term.session); i += 1) {
|
|
605
605
|
await phantomStop(term.session);
|
|
606
606
|
term = await taskTerminal(t.id);
|
|
607
607
|
}
|
package/src/share.js
CHANGED
|
@@ -46,7 +46,7 @@ function knownHosts(port) {
|
|
|
46
46
|
GitHub mirror, the door itself. Reading them is fine; changing — 403 to a guest. Backups are not read either
|
|
47
47
|
(`OWNER_READ`): one is the whole database, `share.key` included, and the key is the door's secret. The board itself
|
|
48
48
|
(tasks, the chat and terminals of Claude, answers to its questions) is shared, as the owner asked. */
|
|
49
|
-
const OWNER_READ = [/^\/api\/backups(\/|$)/];
|
|
49
|
+
const OWNER_READ = [/^\/api\/backups(\/|$)/, /^\/api\/health\/stands(\/|$)/];
|
|
50
50
|
const OWNER_ONLY = [/^\/api\/claude\/hooks/, /^\/api\/update/, /^\/api\/fs\/pick/, /^\/api\/skills/,
|
|
51
51
|
/^\/api\/projects\/(clone|server-git|ssh-check)/, /^\/api\/projects\/[^/]+\/docs/, /^\/api\/sync\/config/, /^\/api\/share/, /^\/api\/flow/,
|
|
52
52
|
/^\/api\/tasks\/[^/]+\/chat\/run/]; // the chat's command opens a Terminal window on the owner's screen
|
package/src/stands.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { realpath, stat, statfs } from 'node:fs/promises';
|
|
4
|
+
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { db } from './db.js';
|
|
7
|
+
import { readRegistry, stateOf } from './claude-live.js';
|
|
8
|
+
import { bin } from './bin-path.js';
|
|
9
|
+
|
|
10
|
+
const GIT_MS = 20_000;
|
|
11
|
+
const DU_MS = 120_000;
|
|
12
|
+
const AT_ONCE = 4; // folders walked side by side
|
|
13
|
+
export const STANDS_FRESH_MS = 10 * 60_000; // an older picture is walked again on the next look
|
|
14
|
+
export const FORCE_EVERY_MS = 15_000; // «Check again» — not more often
|
|
15
|
+
const WORK = new Set(['prep', 'doing', 'ready', 'deploy']);
|
|
16
|
+
const DIFF_MAX = 200_000;
|
|
17
|
+
|
|
18
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' };
|
|
19
|
+
function run(cmd, args, { cwd, timeout = GIT_MS, max = 32 * 1024 * 1024 } = {}) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
execFile(cmd, args, { cwd, timeout, maxBuffer: max, env }, (err, out) => resolve(err ? { err, out: String(out || '') } : { out: String(out) }));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const git = (dir, args, opts) => run(bin('git'), ['-C', dir, ...args], opts);
|
|
25
|
+
const real = async (p) => { try { return await realpath(p); } catch { return p; } };
|
|
26
|
+
export const within = (folder, path) => {
|
|
27
|
+
const rel = relative(folder, path);
|
|
28
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** `git worktree list --porcelain` read: [{path, head, branch, detached, locked, prunable, bare}] — the first is the main
|
|
32
|
+
checkout. Pure. */
|
|
33
|
+
export function parseWorktrees(text) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const block of String(text).split(/\n\s*\n/)) {
|
|
36
|
+
const w = {};
|
|
37
|
+
for (const line of block.split('\n')) {
|
|
38
|
+
const [k, ...rest] = line.split(' ');
|
|
39
|
+
const v = rest.join(' ');
|
|
40
|
+
if (k === 'worktree') w.path = v;
|
|
41
|
+
else if (k === 'HEAD') w.head = v;
|
|
42
|
+
else if (k === 'branch') w.branch = v.replace(/^refs\/heads\//, '');
|
|
43
|
+
else if (k === 'detached') w.detached = true;
|
|
44
|
+
else if (k === 'locked') w.locked = v || true;
|
|
45
|
+
else if (k === 'prunable') w.prunable = v || true;
|
|
46
|
+
else if (k === 'bare') w.bare = true;
|
|
47
|
+
}
|
|
48
|
+
if (w.path) out.push(w);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The key of a task in the name of a folder or a branch («ck-559-kanban-task-0e9ea3», «claude/IB-830») — upper case —
|
|
54
|
+
whose prefix is one of `prefixes`; null. A hash tail («pdf-ee85f1») is no key. Pure. */
|
|
55
|
+
export function keyIn(names, prefixes) {
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
for (const m of String(name || '').matchAll(/(?:^|[^a-z0-9])([a-z][a-z0-9]{0,9})-(\d{1,6})(?=$|[^a-z0-9])/gi)) {
|
|
58
|
+
const prefix = m[1].toUpperCase();
|
|
59
|
+
if (prefixes.has(prefix)) return `${prefix}-${Number(m[2])}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* The issues a message names: «(lamaohub/server-comp#6144)», «#6144». A bare number of a few digits is no proof of the same
|
|
66
|
+
work — only `owner/repo#N` counts, the form the board's commits carry. */
|
|
67
|
+
const ISSUE = /\b[\w.-]+\/[\w.-]+#(\d+)\b/g;
|
|
68
|
+
export const issuesIn = (msg) => [...String(msg || '').matchAll(ISSUE)].map((m) => m[1]);
|
|
69
|
+
|
|
70
|
+
/** A title that names its work: three words and fifteen characters at least — the board's commits are sentences; «WIP»,
|
|
71
|
+
«Fix tests», «Update README» stand in any long log for other work. */
|
|
72
|
+
export const telling = (title) => String(title || '').trim().length >= 15 && String(title).trim().split(/\s+/).length >= 3;
|
|
73
|
+
/** How the commits of a branch stand against main: each commit {sha, title, found: 'patch'|'title'|'issue'|null}.
|
|
74
|
+
`cherry` — the lines of `git cherry <main> <head>` («+ sha» not in main as a patch, «- sha» is); `main` — {titles: Set,
|
|
75
|
+
issues: Set} of the log of main; `messages` — sha → {title, body}. Pure. */
|
|
76
|
+
export function matchCommits(cherry, messages, main) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const line of String(cherry).split('\n')) {
|
|
79
|
+
const m = /^([+-]) ([0-9a-f]{7,64})/.exec(line.trim());
|
|
80
|
+
if (!m) continue;
|
|
81
|
+
const msg = messages.get(m[2]) || { title: '', body: '' };
|
|
82
|
+
let found = null;
|
|
83
|
+
if (m[1] === '-') found = 'patch';
|
|
84
|
+
else if (telling(msg.title) && main.titles.has(msg.title)) found = 'title';
|
|
85
|
+
else {
|
|
86
|
+
const issues = issuesIn(`${msg.title}\n${msg.body}`);
|
|
87
|
+
if (issues.length && issues.every((n) => main.issues.has(n))) found = 'issue';
|
|
88
|
+
}
|
|
89
|
+
out.push({ sha: m[2], title: msg.title, found });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The state of a stand from what was read of it. Pure: {state: 'reading'|'busy'|'unsent'|'free', why: [words…]}. */
|
|
95
|
+
export function standState(s) {
|
|
96
|
+
if (s.read === false) return { state: 'reading', why: [] }; // the walk has not come to it yet
|
|
97
|
+
const why = [];
|
|
98
|
+
if (s.sessions) why.push('session');
|
|
99
|
+
if (s.processes) why.push('process');
|
|
100
|
+
if (s.task && WORK.has(s.task.status)) why.push('task');
|
|
101
|
+
if (why.length) return { state: 'busy', why };
|
|
102
|
+
if (s.locked) why.push('locked');
|
|
103
|
+
if (s.dirty === null || s.commits === null || s.processes === null) why.push('unknown'); // git or lsof did not answer, or no main to measure by
|
|
104
|
+
if (s.dirty) why.push('dirty');
|
|
105
|
+
if (s.commits?.some((c) => !c.found)) why.push('commits');
|
|
106
|
+
if (s.detached && s.commits?.length) why.push('detached');
|
|
107
|
+
if (why.length) return { state: 'unsent', why };
|
|
108
|
+
return { state: 'free', why: [] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The main branch of the origin to measure against: origin/main, origin/master, else the local main/master; null. */
|
|
112
|
+
async function mainRef(top) {
|
|
113
|
+
for (const ref of ['refs/remotes/origin/main', 'refs/remotes/origin/master', 'refs/heads/main', 'refs/heads/master']) {
|
|
114
|
+
const r = await git(top, ['rev-parse', '--verify', '--quiet', ref]);
|
|
115
|
+
if (!r.err && r.out.trim()) return ref.replace(/^refs\/(remotes|heads)\//, '');
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The titles and issue numbers in the log of main (the newest 50 000 commits). */
|
|
121
|
+
async function mainLog(top, ref) {
|
|
122
|
+
const r = await git(top, ['log', '--max-count=50000', '--format=%s%x1f%b%x1e', ref], { max: 256 * 1024 * 1024, timeout: 60_000 });
|
|
123
|
+
const titles = new Set();
|
|
124
|
+
const issues = new Set();
|
|
125
|
+
if (r.err) return { titles, issues, failed: true };
|
|
126
|
+
for (const rec of r.out.split('\x1e')) {
|
|
127
|
+
const [title, body = ''] = rec.split('\x1f');
|
|
128
|
+
const t = title.trim();
|
|
129
|
+
if (t) titles.add(t);
|
|
130
|
+
for (const n of issuesIn(`${t}\n${body}`)) issues.add(n);
|
|
131
|
+
}
|
|
132
|
+
return { titles, issues };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The folders where some process stands (its cwd) — one call to lsof; null when lsof did not answer. */
|
|
136
|
+
async function processFolders() {
|
|
137
|
+
const r = await run(bin('lsof'), ['-n', '-P', '-a', '-d', 'cwd', '-Fn'], { timeout: 30_000, max: 64 * 1024 * 1024 });
|
|
138
|
+
// lsof exits 1 when some processes could not be read — the rest is still there; killed by the timeout, the list is partial
|
|
139
|
+
if (r.err && (!r.out || r.err.killed || r.err.signal)) return null;
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const line of r.out.split('\n')) if (line.startsWith('n/')) out.push(line.slice(1));
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let picture = null; // the last walk: {at, done, total, repos, stands, running}
|
|
146
|
+
let flight = null;
|
|
147
|
+
let forcedAt = 0;
|
|
148
|
+
|
|
149
|
+
/** What is known of the stands now; a walk starts when none is known, the picture is older than STANDS_FRESH_MS, or on
|
|
150
|
+
`force` (not more often than FORCE_EVERY_MS). */
|
|
151
|
+
// the folders of the board's projects: a project added, moved or archived since the walk makes the picture old at once
|
|
152
|
+
const foldersNow = () => JSON.stringify(db.prepare("SELECT path FROM projects WHERE archived = 0 AND path IS NOT NULL AND path != '' ORDER BY id").all().map((r) => r.path));
|
|
153
|
+
export function standsView({ force = false } = {}) {
|
|
154
|
+
const old = !picture || Date.now() - picture.at > STANDS_FRESH_MS || picture.folders !== foldersNow();
|
|
155
|
+
if (!flight && (old || (force && Date.now() - forcedAt > FORCE_EVERY_MS))) {
|
|
156
|
+
if (force) forcedAt = Date.now();
|
|
157
|
+
if (picture) picture.running = true; // the page asks again until the new walk has its picture
|
|
158
|
+
flight = walk().catch((e) => { picture = { ...(picture || emptyPicture()), running: false, error: e.message }; }).finally(() => { flight = null; });
|
|
159
|
+
}
|
|
160
|
+
return shown(picture || { ...emptyPicture(), running: true });
|
|
161
|
+
}
|
|
162
|
+
const emptyPicture = () => ({ at: Date.now(), done: 0, total: 0, repos: [], stands: [], running: true, folders: foldersNow() });
|
|
163
|
+
const shown = (p) => ({ at: p.at, running: Boolean(p.running), done: p.done, total: p.total, error: p.error || null,
|
|
164
|
+
repos: p.repos.map(({ log, ...r }) => r), stands: p.stands.map(({ messages, ...s }) => ({ ...s, ...standState(s) })) });
|
|
165
|
+
/** For a test: wait for the walk in flight. */
|
|
166
|
+
export const standsSettled = () => flight || Promise.resolve();
|
|
167
|
+
/** For a test: forget the last picture. */
|
|
168
|
+
export function forgetStands() { picture = null; forcedAt = 0; }
|
|
169
|
+
|
|
170
|
+
/** The repositories of the board's projects: [{top, common, projects: [{id, slug, name, prefix, path}]}]. */
|
|
171
|
+
async function boardRepos() {
|
|
172
|
+
const projects = db.prepare('SELECT id, slug, name, prefix, path FROM projects WHERE archived = 0 AND path IS NOT NULL AND path != \'\' ORDER BY position, name COLLATE NOCASE').all();
|
|
173
|
+
const byCommon = new Map();
|
|
174
|
+
for (const p of projects) {
|
|
175
|
+
if (!existsSync(p.path)) continue;
|
|
176
|
+
const r = await git(p.path, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
177
|
+
if (r.err) continue;
|
|
178
|
+
const common = await real(r.out.trim());
|
|
179
|
+
if (!byCommon.has(common)) byCommon.set(common, { common, projects: [] });
|
|
180
|
+
byCommon.get(common).projects.push(p);
|
|
181
|
+
}
|
|
182
|
+
return [...byCommon.values()];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function walk() {
|
|
186
|
+
const prefixes = new Map(db.prepare('SELECT prefix, id FROM projects').all().map((r) => [String(r.prefix).toUpperCase(), r.id]));
|
|
187
|
+
const taskOf = db.prepare(`SELECT t.id, t.status, t.title, p.slug, p.name AS project_name FROM tasks t JOIN projects p ON p.id = t.project_id
|
|
188
|
+
WHERE p.prefix = ? COLLATE NOCASE AND t.task_no = ?`);
|
|
189
|
+
const next = { at: Date.now(), done: 0, total: 0, repos: [], stands: [], running: true, folders: foldersNow() };
|
|
190
|
+
for (const repo of await boardRepos()) {
|
|
191
|
+
const list = await git(repo.projects[0].path, ['worktree', 'list', '--porcelain']);
|
|
192
|
+
if (list.err) continue;
|
|
193
|
+
const all = parseWorktrees(list.out);
|
|
194
|
+
const top = all[0]?.path;
|
|
195
|
+
if (!top) continue;
|
|
196
|
+
const ref = await mainRef(top);
|
|
197
|
+
let free = null;
|
|
198
|
+
try { const f = await statfs(top); free = Number(f.bavail) * Number(f.bsize); } catch { /* not known */ }
|
|
199
|
+
const r = { top, main: ref, free, projects: repo.projects.map(({ slug, name }) => ({ slug, name })), log: null };
|
|
200
|
+
next.repos.push(r);
|
|
201
|
+
for (const w of all.slice(1)) {
|
|
202
|
+
if (w.bare) continue;
|
|
203
|
+
const key = keyIn([basename(w.path), w.branch], new Set(prefixes.keys()));
|
|
204
|
+
const t = key ? taskOf.get(key.split('-')[0], Number(key.split('-')[1])) : null;
|
|
205
|
+
// the project of the stand: its key's, else the project of the repository whose folder is its top, else the first
|
|
206
|
+
const own = (t && repo.projects.find((p) => p.slug === t.slug)) || repo.projects.find((p) => p.path === top) || repo.projects[0];
|
|
207
|
+
next.stands.push({
|
|
208
|
+
path: w.path, top, branch: w.branch || null, head: w.head || null, detached: Boolean(w.detached),
|
|
209
|
+
locked: w.locked ? (w.locked === true ? '' : w.locked) : null, prunable: Boolean(w.prunable),
|
|
210
|
+
project: { slug: own.slug, name: own.name }, key,
|
|
211
|
+
task: t ? { id: t.id, status: t.status, title: t.title } : null,
|
|
212
|
+
read: false, dirty: null, changes: null, commits: null, sessions: 0, processes: 0, size: null, changed_at: null, missing: false,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
next.total = next.stands.length;
|
|
217
|
+
// the sizes of the last walk stay on until measured anew: a stand does not jump to «—» on «Check again»
|
|
218
|
+
const before = new Map((picture?.stands || []).map((s) => [s.path, s]));
|
|
219
|
+
for (const s of next.stands) { const b = before.get(s.path); if (b) { s.size = b.size; s.changed_at = b.changed_at; } }
|
|
220
|
+
picture = next;
|
|
221
|
+
|
|
222
|
+
const registry = [...(await readRegistry()).values()].filter((e) => e.cwd && stateOf(e) !== 'gone');
|
|
223
|
+
const sessionDirs = await Promise.all(registry.map((e) => real(e.cwd)));
|
|
224
|
+
const procDirs = await processFolders();
|
|
225
|
+
const logs = new Map();
|
|
226
|
+
const logOf = (top, ref) => {
|
|
227
|
+
if (!logs.has(top)) logs.set(top, ref ? mainLog(top, ref) : Promise.resolve({ titles: new Set(), issues: new Set(), failed: true }));
|
|
228
|
+
return logs.get(top);
|
|
229
|
+
};
|
|
230
|
+
const reposByTop = new Map(next.repos.map((r) => [r.top, r]));
|
|
231
|
+
|
|
232
|
+
const one = async (s) => {
|
|
233
|
+
const dir = await real(s.path);
|
|
234
|
+
s.sessions = sessionDirs.filter((d) => within(dir, d)).length;
|
|
235
|
+
s.processes = procDirs ? procDirs.filter((d) => within(dir, d)).length : null; // null — not known: no stand is free by it
|
|
236
|
+
// no folder (deleted by hand, `prunable`): nothing in it to lose, but its branch is still compared with main below
|
|
237
|
+
if (!existsSync(s.path)) { s.missing = true; s.dirty = 0; s.changes = { files: 0 }; }
|
|
238
|
+
const st = s.missing ? null : await git(s.path, ['status', '--porcelain', '--untracked-files=normal']);
|
|
239
|
+
if (!st) { /* the folder is gone */ } else if (st.err) { s.dirty = null; } else {
|
|
240
|
+
const lines = st.out.split('\n').filter(Boolean);
|
|
241
|
+
s.dirty = lines.length;
|
|
242
|
+
s.changes = { files: lines.length, untracked: lines.filter((l) => l.startsWith('??')).length };
|
|
243
|
+
}
|
|
244
|
+
const ref = reposByTop.get(s.top)?.main;
|
|
245
|
+
if (!ref || !s.head) { s.commits = null; return; }
|
|
246
|
+
const ch = await git(s.top, ['cherry', ref, s.head]);
|
|
247
|
+
if (ch.err) { s.commits = null; return; }
|
|
248
|
+
const shas = ch.out.split('\n').map((l) => l.trim().slice(2)).filter(Boolean);
|
|
249
|
+
const messages = new Map();
|
|
250
|
+
if (shas.length) {
|
|
251
|
+
const lg = await git(s.top, ['log', '--no-walk=unsorted', '--format=%H%x1f%s%x1f%b%x1e', ...shas.slice(0, 500)]);
|
|
252
|
+
for (const rec of (lg.out || '').split('\x1e')) {
|
|
253
|
+
const [sha, title = '', body = ''] = rec.replace(/^\n/, '').split('\x1f');
|
|
254
|
+
if (sha) messages.set(sha.trim(), { title: title.trim(), body });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const main = await logOf(s.top, ref);
|
|
258
|
+
s.commits = matchCommits(ch.out, messages, main).map((c) => ({ ...c, sha: c.sha.slice(0, 12) }));
|
|
259
|
+
if (s.missing) return;
|
|
260
|
+
const lc = await git(s.path, ['log', '-1', '--format=%ct', 'HEAD']);
|
|
261
|
+
s.changed_at = Number(lc.out?.trim()) * 1000 || null;
|
|
262
|
+
try { // the index moves with every add, commit and checkout in the folder
|
|
263
|
+
const gd = await git(s.path, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
264
|
+
const idx = await stat(join(gd.out.trim(), 'index'));
|
|
265
|
+
s.changed_at = Math.max(s.changed_at || 0, idx.mtimeMs);
|
|
266
|
+
} catch { /* the date of the commit stays */ }
|
|
267
|
+
};
|
|
268
|
+
await pool(next.stands, one, (s) => { s.read = true; next.done += 1; });
|
|
269
|
+
// sizes last: the states are what the page needs first
|
|
270
|
+
await pool(next.stands.filter((s) => !s.missing), async (s) => {
|
|
271
|
+
const du = await run(bin('du'), ['-sk', s.path], { timeout: DU_MS });
|
|
272
|
+
const kb = Number((du.out || '').split(/\s/)[0]);
|
|
273
|
+
if (Number.isFinite(kb) && kb > 0) s.size = kb * 1024;
|
|
274
|
+
});
|
|
275
|
+
next.running = false;
|
|
276
|
+
next.at = Date.now();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function pool(items, fn, after = () => {}) {
|
|
280
|
+
let i = 0;
|
|
281
|
+
const worker = async () => {
|
|
282
|
+
while (i < items.length) {
|
|
283
|
+
const item = items[i++];
|
|
284
|
+
try { await fn(item); } catch { /* one stand that could not be read stays unread */ }
|
|
285
|
+
after(item);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
await Promise.all(Array.from({ length: Math.min(AT_ONCE, items.length) }, worker));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A stand of the last picture by its folder — the only folders the routes below may name. */
|
|
292
|
+
export const knownStand = (path) => picture?.stands.find((s) => s.path === path) || null;
|
|
293
|
+
|
|
294
|
+
/** What of a stand is not in main, for the eyes of the owner: the patches of its commits found nowhere in main, then the
|
|
295
|
+
changes not committed — capped at DIFF_MAX characters. */
|
|
296
|
+
export async function standDiff(path) {
|
|
297
|
+
const s = knownStand(path);
|
|
298
|
+
if (!s) return null;
|
|
299
|
+
const parts = [];
|
|
300
|
+
const lost = (s.commits || []).filter((c) => !c.found).map((c) => c.sha);
|
|
301
|
+
if (lost.length) {
|
|
302
|
+
const r = await git(s.top, ['show', '--stat', '--patch', '--format=commit %H%n%s%n', ...lost.slice(0, 50)], { max: 64 * 1024 * 1024 });
|
|
303
|
+
parts.push(r.out);
|
|
304
|
+
}
|
|
305
|
+
if (s.dirty && existsSync(s.path)) {
|
|
306
|
+
const r = await git(s.path, ['diff', 'HEAD', '--stat', '--patch'], { max: 64 * 1024 * 1024 });
|
|
307
|
+
const u = await git(s.path, ['ls-files', '--others', '--exclude-standard']);
|
|
308
|
+
parts.push(`# not committed\n${r.out}${u.out.trim() ? `\n# untracked\n${u.out}` : ''}`);
|
|
309
|
+
}
|
|
310
|
+
const text = parts.join('\n');
|
|
311
|
+
return { text: text.length > DIFF_MAX ? `${text.slice(0, DIFF_MAX)}\n… (cut at ${DIFF_MAX} characters)` : text, cut: text.length > DIFF_MAX };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* The task of the cleanup: one per repository, in the project of most of its chosen stands. Its Claude checks each stand
|
|
315
|
+
again right before removing it — the picture may be minutes old, and a session may have opened in a folder since. */
|
|
316
|
+
const TITLES = { ru: 'Убрать старые стенды', en: 'Remove old stands' };
|
|
317
|
+
export const STAND_TITLES = TITLES;
|
|
318
|
+
const gb = (n) => (n ? `${(n / 1024 ** 3).toFixed(1)} GB` : '?');
|
|
319
|
+
export function cleanupWords(lang, repo, chosen, unsent) {
|
|
320
|
+
const list = chosen.map((s) => `- ${s.path} — ${s.branch ? `branch ${s.branch}` : 'detached HEAD'}${s.head ? ` @ ${s.head.slice(0, 12)}` : ''}${s.size ? ` · ${gb(s.size)}` : ''}${s.missing ? ' · no folder: `git worktree prune`' : ''}`).join('\n');
|
|
321
|
+
const asks = unsent.map((s) => `- ${s.path}${s.branch ? ` (${s.branch})` : ''}: ${standState(s).why.join(', ')}`).join('\n');
|
|
322
|
+
if (lang === 'en') {
|
|
323
|
+
return `Remove the old stands (git worktrees) of ${repo.top}, chosen in Health → Stands: ${chosen.length}.\n\n${list}\n\n`
|
|
324
|
+
+ 'For EACH stand, right before removing it, check all three again — skip it and say why if any fails:\n'
|
|
325
|
+
+ '1. nothing uncommitted or untracked: `git -C <folder> status --porcelain` is empty;\n'
|
|
326
|
+
+ `2. every commit of the branch is in ${repo.main || 'origin/main'}: \`git fetch origin\`, then \`git cherry ${repo.main || 'origin/main'} <branch>\`; a «+» commit counts only if the same title or the same issue number is in \`git log ${repo.main || 'origin/main'}\` — otherwise it is work not handed in;\n`
|
|
327
|
+
+ '3. not busy: no Claude session and no process in the folder (`lsof +D <folder>`), the task of its key is not in prep/doing/ready/deploy.\n\n'
|
|
328
|
+
+ 'Then `git worktree remove <folder>` (never --force) and `git branch -d <branch>` (never -D; refused — leave the branch and say so). '
|
|
329
|
+
+ 'Do not touch the main checkout, the branch main, other projects or the stash.\n\n'
|
|
330
|
+
+ 'In the report: per stand — removed or kept and why, and the last commit of each removed branch (`<sha> <branch>`): it lives on in the reflog and can be brought back with `git branch <branch> <sha>`.'
|
|
331
|
+
+ (asks ? `\n\nNot in this task — work not in main, ask the owner what to do with each:\n${asks}` : '')
|
|
332
|
+
+ '\n\nThe board opened this task: Health → Stands.';
|
|
333
|
+
}
|
|
334
|
+
return `Убрать старые стенды (рабочие копии git) в ${repo.top}, выбранные в «Здоровье → Стенды»: ${chosen.length}.\n\n${list}\n\n`
|
|
335
|
+
+ 'По КАЖДОМУ стенду прямо перед удалением ещё раз проверить все три условия — не прошло хоть одно, стенд не трогать и сказать почему:\n'
|
|
336
|
+
+ '1. нет незакоммиченного и неотслеживаемого: `git -C <папка> status --porcelain` пуст;\n'
|
|
337
|
+
+ `2. каждый коммит ветки есть в ${repo.main || 'origin/main'}: \`git fetch origin\`, затем \`git cherry ${repo.main || 'origin/main'} <ветка>\`; коммит с «+» считается, только если тот же заголовок или тот же номер issue есть в \`git log ${repo.main || 'origin/main'}\` — иначе это несданная работа;\n`
|
|
338
|
+
+ '3. не занят: в папке нет сессии Claude и процесса (`lsof +D <папка>`), задача ключа не в prep/doing/ready/deploy.\n\n'
|
|
339
|
+
+ 'Затем `git worktree remove <папка>` (без --force) и `git branch -d <ветка>` (не -D; отказал — ветку оставить и сказать). '
|
|
340
|
+
+ 'Основной чекаут, ветку main, чужие проекты и stash не трогать.\n\n'
|
|
341
|
+
+ 'В отчёте: по каждому стенду — удалён или оставлен и почему, и последний коммит каждой удалённой ветки (`<sha> <ветка>`): она ещё живёт в reflog, вернуть — `git branch <ветка> <sha>`.'
|
|
342
|
+
+ (asks ? `\n\nНе в этой задаче — работа, которой нет в main, спросить владельца, что делать с каждым:\n${asks}` : '')
|
|
343
|
+
+ '\n\nЗадачу завела доска: «Здоровье → Стенды».';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The tasks of a cleanup of `paths` (folders of the last picture): only stands `free` by it; one task per repository —
|
|
347
|
+
the open one of the same title in that project (Backlog/To do: its description is written anew), else a new one in To
|
|
348
|
+
do. [{key, project, n}] or {code, error, reason}. */
|
|
349
|
+
export async function cleanupTasks(app, paths, lang, headers) {
|
|
350
|
+
if (!picture) return { code: 409, reason: 'not-walked', error: 'the stands have not been looked at yet' };
|
|
351
|
+
const want = new Set(Array.isArray(paths) ? paths.filter((p) => typeof p === 'string') : []);
|
|
352
|
+
const shownNow = shown(picture).stands;
|
|
353
|
+
const chosen = shownNow.filter((s) => want.has(s.path) && s.state === 'free');
|
|
354
|
+
if (!chosen.length) return { code: 409, reason: 'nothing-free', error: 'none of the chosen stands can be removed' };
|
|
355
|
+
const byTop = new Map();
|
|
356
|
+
for (const s of chosen) { if (!byTop.has(s.top)) byTop.set(s.top, []); byTop.get(s.top).push(s); }
|
|
357
|
+
// one cleanup a repository, whichever of its projects holds it: two Claudes removing worktrees of one repository at once
|
|
358
|
+
// would race. All are looked at before any task is written — a refusal for the second must not lose the first one's task
|
|
359
|
+
const openOf = db.prepare(`SELECT p.slug, p.prefix || '-' || t.task_no AS key, t.status FROM tasks t JOIN projects p ON p.id = t.project_id
|
|
360
|
+
WHERE p.slug IN (SELECT value FROM json_each(?)) AND t.title IN (?, ?) AND t.status NOT IN ('done', 'cancelled') ORDER BY t.id DESC LIMIT 1`);
|
|
361
|
+
const plan = [];
|
|
362
|
+
for (const [top, list] of byTop) {
|
|
363
|
+
const repo = picture.repos.find((r) => r.top === top);
|
|
364
|
+
const open = openOf.get(JSON.stringify(repo.projects.map((p) => p.slug)), TITLES.ru, TITLES.en);
|
|
365
|
+
if (open && !['backlog', 'todo'].includes(open.status)) {
|
|
366
|
+
return { code: 409, reason: 'cleanup-open', key: open.key, error: `a cleanup of these stands is already ${open.status === 'review' ? 'waiting for your review' : 'in work'}: ${open.key}` };
|
|
367
|
+
}
|
|
368
|
+
const count = new Map();
|
|
369
|
+
for (const s of list) count.set(s.project.slug, (count.get(s.project.slug) || 0) + 1);
|
|
370
|
+
plan.push({ repo, list, open, slug: open?.slug || [...count].sort((a, b) => b[1] - a[1])[0][0] });
|
|
371
|
+
}
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const { repo, list, open, slug } of plan) {
|
|
374
|
+
const description = cleanupWords(lang, repo, list, shownNow.filter((s) => s.top === repo.top && s.state === 'unsent'));
|
|
375
|
+
let key;
|
|
376
|
+
if (open) {
|
|
377
|
+
const res = await app.inject({ method: 'PATCH', url: `/api/tasks/${encodeURIComponent(open.key)}`, headers, payload: { description, status: 'todo' } });
|
|
378
|
+
if (res.statusCode >= 300) return { code: res.statusCode, error: res.json?.().error || 'could not update the task' };
|
|
379
|
+
key = open.key;
|
|
380
|
+
} else {
|
|
381
|
+
const res = await app.inject({ method: 'POST', url: '/api/tasks', headers, payload: { project: slug, title: TITLES[lang === 'en' ? 'en' : 'ru'], description, status: 'todo' } });
|
|
382
|
+
if (res.statusCode >= 300) return { code: res.statusCode, error: res.json().error || 'could not open the task' };
|
|
383
|
+
key = res.json().key;
|
|
384
|
+
}
|
|
385
|
+
out.push({ key, project: slug, n: list.length });
|
|
386
|
+
}
|
|
387
|
+
return { tasks: out };
|
|
388
|
+
}
|