@sejuneoh/aitop 0.0.0-stage → 0.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -2
- package/bin/aitop.mjs +360 -0
- package/package.json +36 -4
- package/src/attention.mjs +70 -0
- package/src/brand.mjs +30 -0
- package/src/budget.mjs +29 -0
- package/src/cache.mjs +78 -0
- package/src/collect.mjs +51 -0
- package/src/config.mjs +169 -0
- package/src/doctor.mjs +218 -0
- package/src/frames.mjs +24 -0
- package/src/help.mjs +111 -0
- package/src/json.mjs +57 -0
- package/src/keys.mjs +154 -0
- package/src/log.mjs +130 -0
- package/src/notify.mjs +251 -0
- package/src/order.mjs +69 -0
- package/src/pricing.mjs +130 -0
- package/src/procs.mjs +205 -0
- package/src/sources/agy-ledger.mjs +153 -0
- package/src/sources/antigravity.mjs +763 -0
- package/src/sources/claude-ledger.mjs +181 -0
- package/src/sources/claude-tracker.mjs +183 -0
- package/src/sources/claude.mjs +352 -0
- package/src/sources/codex-ledger.mjs +141 -0
- package/src/sources/codex.mjs +374 -0
- package/src/sources/common.mjs +228 -0
- package/src/sources/files.mjs +116 -0
- package/src/sources/gemini.mjs +26 -0
- package/src/sources/patch.mjs +78 -0
- package/src/sqlite.mjs +50 -0
- package/src/tail.mjs +109 -0
- package/src/text.mjs +256 -0
- package/src/turns.mjs +79 -0
- package/src/ui/chrome.mjs +163 -0
- package/src/ui/detail.mjs +360 -0
- package/src/ui/draw.mjs +152 -0
- package/src/ui/glyphs.mjs +58 -0
- package/src/ui/table.mjs +425 -0
- package/src/ui/theme.mjs +137 -0
- package/src/ui.mjs +99 -0
package/src/json.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { projectBudget } from './budget.mjs';
|
|
2
|
+
import { toolsJson } from './doctor.mjs';
|
|
3
|
+
|
|
4
|
+
// aitop --json 출력. 다른 스크립트나 AI 가 읽기 쉬운 모양으로 세션 목록과 사용량 합계를 담는다.
|
|
5
|
+
// 텍스트는 수집 단계에서 이미 비밀값 가림이 적용돼 있다.
|
|
6
|
+
|
|
7
|
+
const pickTokens = (t = {}) => ({ total: t.total ?? 0, input: t.input ?? 0, output: t.output ?? 0, cacheRead: t.cacheRead ?? 0, cacheWrite: t.cacheWrite ?? 0, main: t.main ?? null, sub: t.sub ?? null, subCount: t.subCount ?? null });
|
|
8
|
+
|
|
9
|
+
const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
|
|
10
|
+
// 비용(달러)은 소수 넷째 자리까지 반올림한다.
|
|
11
|
+
const money = (x) => (typeof x === 'number' ? Math.round(x * 1e4) / 1e4 : (x ?? null));
|
|
12
|
+
// 최근 1시간 비용 (5분 칸 단위 근사).
|
|
13
|
+
const lastHourOf = (h) => (h ? { cost: money(h.cost), costNoCache: money(h.costNoCache), unknown: Boolean(h.unknown) } : null);
|
|
14
|
+
// last7days.daily: 날짜별 API 환산 비용 (오래된 날 → 오늘). unknown 은 가격표에 없는 모델이 섞인 날.
|
|
15
|
+
const dailyOf = (days) => (days ? { daily: days.map((d) => ({ date: d.date, cost: money(d.cost), unknown: Boolean(d.unknown) })) } : {});
|
|
16
|
+
const usageOf = (u) => (u ? { ...u, ...('cost' in u ? { cost: money(u.cost) } : {}) } : u);
|
|
17
|
+
|
|
18
|
+
// version 은 aitop 버전, tools 는 aitop --doctor 와 같은 도구별 점검 결과다.
|
|
19
|
+
// opts.showRequests: 응답 대기 요청 전체(waitDetail)를 넣는다. 기본은 꺼져 있다.
|
|
20
|
+
// opts.budgetDay: 하루 예산. 있으면 usage.budget 에 예산, 오늘 끝 예상 비용(API 정가 환산, 화면과 같은 기준), 단계를 넣는다.
|
|
21
|
+
export function toJson(data, version = null, opts = {}) {
|
|
22
|
+
const b = data.usage ? projectBudget(data.usage, opts.budgetDay, { now: data.at ?? Date.now() }) : null;
|
|
23
|
+
return {
|
|
24
|
+
version,
|
|
25
|
+
at: iso(data.at),
|
|
26
|
+
tools: toolsJson(data.tools?.tools),
|
|
27
|
+
usage: data.usage ? { today: usageOf(data.usage.today), last7days: { ...usageOf(data.usage.week), ...dailyOf(data.usage.days7) }, lastHour: lastHourOf(data.usage.lastHour), complete: data.usage.loading == null, ...(b ? { budget: { day: b.day, projected: money(b.projected), unknown: b.unknown, level: b.level } } : {}) } : null,
|
|
28
|
+
sessions: data.sessions.map((s) => ({
|
|
29
|
+
tool: s.tool,
|
|
30
|
+
id: s.id,
|
|
31
|
+
name: s.name ?? null,
|
|
32
|
+
project: s.project ?? null,
|
|
33
|
+
cwd: s.cwd ?? null,
|
|
34
|
+
branch: s.branch ?? null,
|
|
35
|
+
model: s.model ?? null,
|
|
36
|
+
kind: s.kind ?? null,
|
|
37
|
+
pid: s.pid ?? null,
|
|
38
|
+
live: Boolean(s.live),
|
|
39
|
+
status: s.status,
|
|
40
|
+
phase: s.phase?.text ?? null,
|
|
41
|
+
waitingFor: s.waitingFor ?? null,
|
|
42
|
+
// 허락을 기다리는 요청의 전체 내용 (Bash 명령, 파일 경로 등). --show-requests 를 켰을 때만 넣는다. 비밀값 가림은 최선을 다할 뿐이다.
|
|
43
|
+
...(opts.showRequests ? { waitDetail: s.waitDetail ?? null } : {}),
|
|
44
|
+
now: s.now || null,
|
|
45
|
+
startedAt: iso(s.startedAt),
|
|
46
|
+
updatedAt: iso(s.updatedAt),
|
|
47
|
+
tokens: pickTokens(s.tokens),
|
|
48
|
+
cost: money(s.cost),
|
|
49
|
+
costNoCache: money(s.costNoCache),
|
|
50
|
+
costUnknown: Boolean(s.costUnknown),
|
|
51
|
+
context: { used: s.context?.used ?? 0, window: s.context?.window ?? null },
|
|
52
|
+
// 변경 내용(diff)은 넣지 않고 파일별 통계만 넣는다. 줄 수는 편집마다 더한 누적값이다 (git diff 아님).
|
|
53
|
+
files: (s.files ?? []).map((f) => ({ path: f.path, created: Boolean(f.created), edits: f.edits, writtenLines: f.add, deletedLines: f.del, updatedAt: iso(f.at), agents: f.agents ?? [] })),
|
|
54
|
+
members: (s.members ?? []).map((m) => ({ name: m.name, task: m.task ?? null, model: m.model ?? null, status: m.status, phase: m.phase?.text ?? null, tokens: m.tokens ?? 0, cost: money(m.cost) })),
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/keys.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// 키 입력 처리. 화면 상태(view)를 바꾸고, 종료·갱신·세션 이동처럼 밖에서 해야 할 일은 act 로 부른다.
|
|
2
|
+
// view.focus: null(세션 선택) → 'files'(변경 파일 목록) → 'diff'(고른 파일의 변경 내용). Esc 는 한 단계씩 뒤로 가고 종료하지 않는다.
|
|
3
|
+
// view.tab: 변경 파일을 보는 지시 탭 ('now' 이번 지시, 지시 번호, 'all' 전체). turns.mjs 참고.
|
|
4
|
+
import { turnTabs, visibleFiles } from './turns.mjs';
|
|
5
|
+
import { detailOpen } from './ui/chrome.mjs';
|
|
6
|
+
import { nextGroup, nextSort, togglePin } from './order.mjs';
|
|
7
|
+
|
|
8
|
+
export const KEY = { up: '\x1b[A', down: '\x1b[B', right: '\x1b[C', left: '\x1b[D', pageUp: '\x1b[5~', pageDown: '\x1b[6~', esc: '\x1b', enter: '\r', tab: '\t', ctrlC: '\x03', backspace: '\x7f' };
|
|
9
|
+
|
|
10
|
+
const isUp = (k) => k === KEY.up || k === 'k';
|
|
11
|
+
const isDown = (k) => k === KEY.down || k === 'j';
|
|
12
|
+
const isBack = (k) => k === KEY.esc || k === 'h';
|
|
13
|
+
|
|
14
|
+
export function selectedSession(view) {
|
|
15
|
+
return view.data?.sessions.find((s) => s.id === view.selectedId) ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const hasFiles = (view) => Boolean(selectedSession(view)?.files?.length);
|
|
19
|
+
// 지금 탭에 보이는 파일
|
|
20
|
+
const filesOf = (view) => visibleFiles(selectedSession(view), view.tab);
|
|
21
|
+
|
|
22
|
+
// 갱신 뒤에 고른 파일이 탭에서 빠졌거나 세션에 고친 파일이 없어졌으면 포커스를 맞춘다.
|
|
23
|
+
export function fixFocus(view) {
|
|
24
|
+
if (!view.focus) return;
|
|
25
|
+
if (!hasFiles(view) || !view.showDetail) {
|
|
26
|
+
view.focus = null;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const files = filesOf(view);
|
|
30
|
+
if (files.some((f) => f.path === view.filePath)) return;
|
|
31
|
+
// 보던 diff 의 파일이 탭에서만 빠졌으면(새 지시가 들어와 이번 지시가 바뀜) 닫지 않고 전체 탭으로 옮긴다.
|
|
32
|
+
if (view.focus === 'diff' && selectedSession(view).files.some((f) => f.path === view.filePath)) {
|
|
33
|
+
view.tab = 'all';
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
view.filePath = files[0]?.path ?? null;
|
|
37
|
+
view.focus = 'files';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 이번 지시에 고친 파일이 없어도 목록에는 들어간다. ←/→ 로 다른 지시를 볼 수 있다.
|
|
41
|
+
function enterFiles(view) {
|
|
42
|
+
if (!hasFiles(view)) return;
|
|
43
|
+
view.showDetail = true;
|
|
44
|
+
view.focus = 'files';
|
|
45
|
+
const files = filesOf(view);
|
|
46
|
+
if (!files.some((f) => f.path === view.filePath)) view.filePath = files[0]?.path ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stepTab(view, delta) {
|
|
50
|
+
const tabs = turnTabs(selectedSession(view));
|
|
51
|
+
const i = Math.max(0, tabs.findIndex((t) => t.key === (view.tab ?? 'now')));
|
|
52
|
+
view.tab = tabs[Math.min(tabs.length - 1, Math.max(0, i + delta))].key;
|
|
53
|
+
const files = filesOf(view);
|
|
54
|
+
if (!files.some((f) => f.path === view.filePath)) view.filePath = files[0]?.path ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stepFile(view, delta) {
|
|
58
|
+
const files = filesOf(view);
|
|
59
|
+
const i = Math.max(0, files.findIndex((f) => f.path === view.filePath));
|
|
60
|
+
view.filePath = files[Math.min(files.length - 1, Math.max(0, i + delta))]?.path ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function filesKey(view, key) {
|
|
64
|
+
if (isUp(key)) stepFile(view, -1);
|
|
65
|
+
else if (isDown(key)) stepFile(view, 1);
|
|
66
|
+
else if (key === KEY.left || key === KEY.right) stepTab(view, key === KEY.left ? -1 : 1);
|
|
67
|
+
else if (key === KEY.enter || key === 'l') {
|
|
68
|
+
if (!view.filePath) return true;
|
|
69
|
+
view.focus = 'diff';
|
|
70
|
+
view.diffScroll = 0;
|
|
71
|
+
} else if (isBack(key) || key === 'f' || key === KEY.tab) view.focus = null;
|
|
72
|
+
else return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function diffKey(view, key) {
|
|
77
|
+
const page = view.diffPage ?? 10;
|
|
78
|
+
if (isUp(key)) view.diffScroll = Math.max(0, (view.diffScroll ?? 0) - 1);
|
|
79
|
+
else if (isDown(key)) view.diffScroll = (view.diffScroll ?? 0) + 1;
|
|
80
|
+
else if (key === ' ' || key === KEY.pageDown) view.diffScroll = (view.diffScroll ?? 0) + page;
|
|
81
|
+
else if (key === KEY.pageUp) view.diffScroll = Math.max(0, (view.diffScroll ?? 0) - page);
|
|
82
|
+
else if (key === '[' || key === ']') {
|
|
83
|
+
stepFile(view, key === '[' ? -1 : 1);
|
|
84
|
+
view.diffScroll = 0;
|
|
85
|
+
} else if (isBack(key) || key === KEY.left) view.focus = 'files';
|
|
86
|
+
else return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 검색 중에는 글자를 검색어로 받는다. Enter 는 검색어를 남기고 목록으로, Esc 는 검색을 지운다.
|
|
91
|
+
function searchKey(view, key, act) {
|
|
92
|
+
if (key === KEY.enter) view.searching = false;
|
|
93
|
+
else if (key === KEY.esc) {
|
|
94
|
+
view.searching = false;
|
|
95
|
+
view.filter = '';
|
|
96
|
+
} else if (key === KEY.backspace || key === '\b') view.filter = [...(view.filter ?? '')].slice(0, -1).join('');
|
|
97
|
+
else if (!/[\x00-\x1f\x7f]/.test(key)) view.filter = (view.filter ?? '') + key;
|
|
98
|
+
act.keepVisible();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 도움말은 모달이다: 열려 있는 동안은 스크롤(↑↓ j k space PgUp PgDn), 닫기(? Esc), 나가기(q)만 듣고 나머지 키는 버린다.
|
|
102
|
+
// 스크롤 끝과 한 페이지 줄 수는 마지막으로 그린 도움말이 정한다 (view.helpMax, view.helpPage).
|
|
103
|
+
function helpKey(view, key, act) {
|
|
104
|
+
if (key === '?' || key === KEY.esc) view.help = false;
|
|
105
|
+
else if (key === 'q') act.quit();
|
|
106
|
+
else {
|
|
107
|
+
const page = view.helpPage ?? 10;
|
|
108
|
+
const step = isUp(key) ? -1 : isDown(key) ? 1 : key === ' ' || key === KEY.pageDown ? page : key === KEY.pageUp ? -page : 0;
|
|
109
|
+
if (step) view.helpScroll = Math.min(view.helpMax ?? 0, Math.max(0, (view.helpScroll ?? 0) + step));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function onKey(view, key, act) {
|
|
114
|
+
if (key === KEY.ctrlC) return act.quit();
|
|
115
|
+
if (view.help) return helpKey(view, key, act);
|
|
116
|
+
if (view.searching) return searchKey(view, key, act);
|
|
117
|
+
if (key === '?') {
|
|
118
|
+
view.help = true;
|
|
119
|
+
view.helpScroll = 0;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (key === 'q') return act.quit();
|
|
123
|
+
if (view.focus === 'diff' && diffKey(view, key)) return;
|
|
124
|
+
if (view.focus === 'files' && filesKey(view, key)) return;
|
|
125
|
+
if (key === KEY.esc) {
|
|
126
|
+
view.filter = '';
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (key === 'f' || key === KEY.tab) return enterFiles(view);
|
|
130
|
+
if (key === '/') {
|
|
131
|
+
view.searching = true;
|
|
132
|
+
view.filter = '';
|
|
133
|
+
view.focus = null;
|
|
134
|
+
} else if (isUp(key)) act.move(-1);
|
|
135
|
+
else if (isDown(key)) act.move(1);
|
|
136
|
+
else if (key === 'n') act.next();
|
|
137
|
+
// 정렬·묶기는 순서만 바꾸고 선택은 같은 세션 id 에 남는다.
|
|
138
|
+
else if (key === 's') view.sort = nextSort(view.sort);
|
|
139
|
+
else if (key === 'g') view.group = nextGroup(view.group);
|
|
140
|
+
// 고정(p)은 이번 실행에만 기억한다. 고른 세션은 그대로 남고 자리만 위로 옮긴다.
|
|
141
|
+
// 파일 목록·diff 에 들어가 있을 때는 키 안내에 없으므로 받지 않는다.
|
|
142
|
+
else if (key === 'p') {
|
|
143
|
+
if (view.selectedId != null && !view.focus) view.pins = togglePin(view.pins, view.selectedId);
|
|
144
|
+
}
|
|
145
|
+
else if (key === 'c') view.costMode = view.costMode === 'net' ? 'api' : 'net';
|
|
146
|
+
else if (key === 'd') {
|
|
147
|
+
// view.rows: 마지막으로 그린 화면 높이. 'auto' 일 때 지금 보이는 상태를 뒤집는 데 쓴다.
|
|
148
|
+
view.showDetail = !detailOpen(view, view.rows ?? Infinity);
|
|
149
|
+
if (!view.showDetail) view.focus = null;
|
|
150
|
+
} else if (key === 'e') {
|
|
151
|
+
view.showEnded = !view.showEnded;
|
|
152
|
+
act.refresh();
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/log.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// 디버그 로그: --debug (AITOP_DEBUG=1, 설정 파일 debug) 일 때만 $XDG_STATE_HOME/aitop/aitop.log 에 남긴다.
|
|
2
|
+
// 기록 형식이 바뀌어 값이 비는 원인을 찾기 위한 것이라, 오류 이름·코드와 짧은 맥락(출처, 파일 이름)만 쓴다.
|
|
3
|
+
// 기록 내용·도구 입력은 쓰지 않고, 쓰는 글은 모두 mask() 와 stripControl() 을 거친다.
|
|
4
|
+
// 꺼져 있으면 파일을 만들지 않고 debug() 는 바로 돌아간다.
|
|
5
|
+
import { closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync, renameSync, statSync, writeSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
8
|
+
import { mask, stripControl } from './text.mjs';
|
|
9
|
+
|
|
10
|
+
// 이 크기를 넘으면 aitop.log.1 로 옮기고 새로 쓴다 (이전 것 하나만 남긴다).
|
|
11
|
+
// aitop 여럿이 같은 파일에 쓸 수 있다: 덧붙이기(O_APPEND)로만 쓰고, 옮기기 전에 그 이름이 아직 내가 연 파일인지와 실제 크기를 다시 본다.
|
|
12
|
+
export const MAX_BYTES = 1024 * 1024;
|
|
13
|
+
// 같은 출처·같은 문구는 이 간격에 한 번만 쓴다.
|
|
14
|
+
export const REPEAT_MS = 60_000;
|
|
15
|
+
const MAX_KEYS = 1000;
|
|
16
|
+
|
|
17
|
+
export function logPath(env = process.env, home = homedir()) {
|
|
18
|
+
const xdg = env.XDG_STATE_HOME;
|
|
19
|
+
return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.local', 'state'), 'aitop', 'aitop.log');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let state = { on: false, path: null };
|
|
23
|
+
|
|
24
|
+
export const logState = () => ({ on: state.on, path: state.path });
|
|
25
|
+
|
|
26
|
+
// 오류에서 원인만 꺼낸다. JSON.parse 의 SyntaxError 문구에는 읽던 내용 일부가 들어가므로 이름만 쓴다.
|
|
27
|
+
function errText(err) {
|
|
28
|
+
if (err == null) return '';
|
|
29
|
+
if (typeof err !== 'object') return String(err);
|
|
30
|
+
// node:sqlite 오류의 문구("database is locked", "no such table: …")는 SQLite 가 정한 말이라 함께 쓴다.
|
|
31
|
+
if (err.code === 'ERR_SQLITE_ERROR') return [err.code, err.errcode, err.errstr, err.message].filter((x) => x != null && x !== '').join(' ').slice(0, 200);
|
|
32
|
+
if (err.code) return [err.code, err.syscall].filter(Boolean).join(' ');
|
|
33
|
+
if (err instanceof SyntaxError) return 'SyntaxError';
|
|
34
|
+
return `${err.name ?? 'Error'}: ${err.message ?? ''}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const clean = (s, max) => mask(stripControl(String(s ?? '').replace(/[\r\n\t]+/g, ' '))).slice(0, max);
|
|
38
|
+
|
|
39
|
+
function openLog(path) {
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
41
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
42
|
+
try {
|
|
43
|
+
// 예전에 다른 권한으로 만들어진 파일이어도 나만 읽게 한다.
|
|
44
|
+
fchmodSync(fd, 0o600);
|
|
45
|
+
const st = fstatSync(fd);
|
|
46
|
+
return { fd, size: st.size, ino: st.ino, dev: st.dev };
|
|
47
|
+
} catch (err) {
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// enabled 가 아니면 아무것도 만들지 않는다. now·maxBytes 는 테스트에서 바꿔 끼운다.
|
|
54
|
+
export function initLog({ enabled = false, path = logPath(), maxBytes = MAX_BYTES, now = Date.now, version = '' } = {}) {
|
|
55
|
+
closeLog();
|
|
56
|
+
state = { on: false, path };
|
|
57
|
+
if (!enabled) return logState();
|
|
58
|
+
try {
|
|
59
|
+
const { fd, size } = openLog(path);
|
|
60
|
+
state = { on: true, path, fd, size, maxBytes, now, seen: new Map() };
|
|
61
|
+
write(`aitop ${clean(version, 40)} 디버그 로그 시작 (pid ${process.pid}, node ${process.versions.node})`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
state = { on: false, path, error: errText(err) };
|
|
64
|
+
}
|
|
65
|
+
return logState();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function closeLog() {
|
|
69
|
+
if (state.fd != null) {
|
|
70
|
+
try {
|
|
71
|
+
closeSync(state.fd);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
state = { on: false, path: state.path };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 다른 aitop 이 먼저 옮겼으면(이름이 가리키는 파일이 바뀌었거나 없으면) 옮기지 않고 새 파일을 다시 연다.
|
|
78
|
+
// 둘이 거의 같이 옮기면 이전 로그 하나를 잃을 수는 있어도, 로그가 꺼지거나 새 로그를 덮지는 않는다.
|
|
79
|
+
function rotate(add) {
|
|
80
|
+
const mine = fstatSync(state.fd);
|
|
81
|
+
closeSync(state.fd);
|
|
82
|
+
state.fd = null;
|
|
83
|
+
try {
|
|
84
|
+
const now = statSync(state.path);
|
|
85
|
+
const same = now.ino === mine.ino && now.dev === mine.dev;
|
|
86
|
+
if (same && now.size > 0 && now.size + add > state.maxBytes) renameSync(state.path, state.path + '.1');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
89
|
+
}
|
|
90
|
+
Object.assign(state, openLog(state.path));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function write(text) {
|
|
94
|
+
const line = Buffer.from(`${new Date(state.now()).toISOString()} ${text}\n`);
|
|
95
|
+
if (state.size + line.length > state.maxBytes && state.size > 0) rotate(line.length);
|
|
96
|
+
writeSync(state.fd, line);
|
|
97
|
+
state.size += line.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// source: 'codex', 'claude' 처럼 어디서 났는지. message: 무엇이 실패했는지 (고정 문구). err: 잡은 오류. file: 경로면 파일 이름만 쓴다.
|
|
101
|
+
export function debug(source, message, err, file) {
|
|
102
|
+
if (!state.on) return;
|
|
103
|
+
try {
|
|
104
|
+
const key = `${source}\0${message}`;
|
|
105
|
+
const t = state.now();
|
|
106
|
+
const prev = state.seen.get(key);
|
|
107
|
+
if (prev && t - prev.at < REPEAT_MS) {
|
|
108
|
+
prev.skipped++;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (state.seen.size >= MAX_KEYS) state.seen.clear();
|
|
112
|
+
state.seen.set(key, { at: t, skipped: 0 });
|
|
113
|
+
const parts = [`[${clean(source, 32)}] ${clean(message, 200)}`];
|
|
114
|
+
const why = errText(err);
|
|
115
|
+
if (why) parts.push(`: ${clean(why, 300)}`);
|
|
116
|
+
if (file) parts.push(` (${clean(basename(String(file)), 120)})`);
|
|
117
|
+
if (prev?.skipped) parts.push(` (+${prev.skipped}번 생략)`);
|
|
118
|
+
write(parts.join(''));
|
|
119
|
+
} catch (e) {
|
|
120
|
+
// 로그를 쓸 수 없으면 끄고 조용히 계속한다. 화면은 로그 때문에 멈추지 않는다.
|
|
121
|
+
closeLog();
|
|
122
|
+
state.error = errText(e);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 파일이 없거나(지워졌거나 아직 안 생김) 경로 중간이 폴더가 아닌 경우는 늘 있는 일이라 남기지 않는다.
|
|
127
|
+
const ROUTINE = new Set(['ENOENT', 'ENOTDIR']);
|
|
128
|
+
export function debugIo(source, message, err, file) {
|
|
129
|
+
if (state.on && !ROUTINE.has(err?.code)) debug(source, message, err, file);
|
|
130
|
+
}
|
package/src/notify.mjs
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// 응답 대기 알림: --notify (데스크톱 알림), --on-waiting <명령> (사용자 명령 실행).
|
|
2
|
+
// 세션 정보는 명령 문자열에 넣지 않는다. 알림 도구에는 인자로, 사용자 명령에는 AITOP_* 환경변수로만 넘긴다.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
5
|
+
import { join, delimiter } from 'node:path';
|
|
6
|
+
import { mask, stripControl } from './text.mjs';
|
|
7
|
+
|
|
8
|
+
export const HOOK_TIMEOUT_MS = 10_000;
|
|
9
|
+
const LABEL_MAX = 120;
|
|
10
|
+
|
|
11
|
+
// 옵션 해석은 여기 한 곳에서 한다. config 에는 설정 파일 값(notify, onWaiting)을 넘기고, 명령줄 옵션이 그 위를 덮는다.
|
|
12
|
+
// argv 에서 --notify, --no-notify, --on-waiting <cmd>, --on-waiting=<cmd>, --no-on-waiting 을 꺼내고 나머지는 rest 로 돌려준다.
|
|
13
|
+
// 값을 따로 받는 다른 옵션(valueOpts)의 값은 건드리지 않고 그대로 넘긴다 (그 옵션의 해석과 같게 - 로 시작하지 않는 다음 인자).
|
|
14
|
+
export const VALUE_OPTS = ['--theme'];
|
|
15
|
+
export function notifyOptions(argv, config = {}, valueOpts = VALUE_OPTS) {
|
|
16
|
+
let notify = config.notify === true;
|
|
17
|
+
let onWaiting = typeof config.onWaiting === 'string' && config.onWaiting.trim() ? config.onWaiting : null;
|
|
18
|
+
let error = null;
|
|
19
|
+
const rest = [];
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (valueOpts.includes(a)) {
|
|
23
|
+
rest.push(a);
|
|
24
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) rest.push(argv[++i]);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (a === '--notify') {
|
|
28
|
+
notify = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (a === '--no-notify') {
|
|
32
|
+
notify = false;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (a === '--no-on-waiting') {
|
|
36
|
+
onWaiting = null;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const m = /^--on-waiting(?:=([\s\S]*))?$/.exec(a);
|
|
40
|
+
if (!m) {
|
|
41
|
+
rest.push(a);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const v = m[1] ?? (i + 1 < argv.length ? argv[++i] : '');
|
|
45
|
+
if (!v.trim()) error = '--on-waiting 값이 없습니다 (실행할 명령)';
|
|
46
|
+
else if (m[1] == null && v.startsWith('-')) error = `--on-waiting 값이 - 로 시작합니다 (${v}). 명령이 맞으면 --on-waiting='${v}' 처럼 = 로 붙여 쓴다`;
|
|
47
|
+
else onWaiting = v;
|
|
48
|
+
}
|
|
49
|
+
return { notify, onWaiting, rest, error };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function executable(path) {
|
|
53
|
+
try {
|
|
54
|
+
accessSync(path, constants.X_OK);
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function which(name, envPath = process.env.PATH ?? '') {
|
|
62
|
+
for (const dir of envPath.split(delimiter)) {
|
|
63
|
+
if (!dir) continue;
|
|
64
|
+
const p = join(dir, name);
|
|
65
|
+
if (executable(p)) return p;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isWsl(env = process.env) {
|
|
71
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
|
|
72
|
+
try {
|
|
73
|
+
return /microsoft/i.test(readFileSync('/proc/version', 'utf8'));
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 쓸 수 있는 데스크톱 알림 도구. 없으면 null (조용히 건너뛴다).
|
|
80
|
+
// Linux·WSL 은 notify-send 만 쓴다. WSL 에서 없으면 powershell 로 대신하지 않는다.
|
|
81
|
+
export function findNotifier({ platform = process.platform, env = process.env } = {}) {
|
|
82
|
+
if (platform === 'darwin') {
|
|
83
|
+
const path = which('osascript', env.PATH);
|
|
84
|
+
return path ? { kind: 'osascript', path } : null;
|
|
85
|
+
}
|
|
86
|
+
if (platform === 'linux') {
|
|
87
|
+
const path = which('notify-send', env.PATH);
|
|
88
|
+
return path ? { kind: 'notify-send', path } : null;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 알림 도구 실행 인자. 제목·본문은 인자로만 넘긴다 (셸·AppleScript 문자열에 넣지 않는다).
|
|
94
|
+
export function notifierCommand(notifier, title, body) {
|
|
95
|
+
if (notifier.kind === 'osascript') {
|
|
96
|
+
return [notifier.path, ['-e', 'on run argv', '-e', 'display notification (item 2 of argv) with title (item 1 of argv)', '-e', 'end run', title, body]];
|
|
97
|
+
}
|
|
98
|
+
// notify-send 는 본문을 마크업으로 읽으므로 <, >, & 를 바꿔 넣는다.
|
|
99
|
+
return [notifier.path, ['--app-name=aitop', '--', escapeMarkup(title), escapeMarkup(body)]];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const escapeMarkup = (t) => String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
103
|
+
|
|
104
|
+
// 세션에서 짧은 대기 사유. "응답 대기 · Bash" → "Bash". 비밀값처럼 보이는 것은 가린다.
|
|
105
|
+
export function waitingLabel(s) {
|
|
106
|
+
const text = String(s.phase?.kind === 'waiting' ? (s.phase.text ?? '') : '').replace(/^응답 대기(?: · )?/, '');
|
|
107
|
+
const one = stripControl(text).replace(/\s+/g, ' ').trim();
|
|
108
|
+
return mask(one.length > LABEL_MAX ? one.slice(0, LABEL_MAX - 1) + '…' : one);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --on-waiting 명령에 넘기는 환경변수.
|
|
112
|
+
export function waitingEnv(s) {
|
|
113
|
+
return {
|
|
114
|
+
AITOP_TOOL: String(s.tool ?? ''),
|
|
115
|
+
AITOP_PROJECT: String(s.project ?? ''),
|
|
116
|
+
AITOP_SESSION_ID: String(s.id ?? ''),
|
|
117
|
+
AITOP_CWD: String(s.cwd ?? ''),
|
|
118
|
+
AITOP_WAITING_FOR: waitingLabel(s),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function notifyText(sessions) {
|
|
123
|
+
if (sessions.length === 1) {
|
|
124
|
+
const s = sessions[0];
|
|
125
|
+
const label = waitingLabel(s);
|
|
126
|
+
return { title: `aitop: 응답 대기 (${s.tool ?? '?'})`, body: stripControl(`${s.project ?? s.id ?? ''}${label ? ' · ' + label : ''}`) };
|
|
127
|
+
}
|
|
128
|
+
return { title: 'aitop: 응답 대기', body: `${sessions.length}개 세션이 응답을 기다립니다` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 한 번에 하나만 실행하는 자식 프로세스 실행기.
|
|
132
|
+
// 실행 중에 새 일이 오면 하나만 대기열에 두고(가장 최근 것으로 덮어씀), 끝나면 그것을 실행한다.
|
|
133
|
+
// 출력은 버리고, timeoutMs 가 지나면 프로세스 그룹째(셸과 그 자식) SIGKILL 한다.
|
|
134
|
+
// 자식은 'exit' 를 받아 거두므로 좀비가 남지 않는다. UI 루프는 기다리지 않는다.
|
|
135
|
+
function killGroup(c, sig) {
|
|
136
|
+
try {
|
|
137
|
+
process.kill(-c.pid, sig);
|
|
138
|
+
} catch {
|
|
139
|
+
try {
|
|
140
|
+
c.kill(sig);
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 실행 중인 모든 자식(이 모듈이 띄운 것). 자식은 따로 프로세스 그룹이라 aitop 이 끝나도 같이 죽지 않으므로,
|
|
146
|
+
// 끝날 때(process 'exit') 그룹째 SIGKILL 한다. 'exit' 안에서는 동기 호출만 되므로 process.kill 만 쓴다.
|
|
147
|
+
const liveChildren = new Set();
|
|
148
|
+
let exitHooked = false;
|
|
149
|
+
|
|
150
|
+
export function killAllChildren() {
|
|
151
|
+
for (const c of liveChildren) killGroup(c, 'SIGKILL');
|
|
152
|
+
liveChildren.clear();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function track(c) {
|
|
156
|
+
liveChildren.add(c);
|
|
157
|
+
if (!exitHooked) {
|
|
158
|
+
exitHooked = true;
|
|
159
|
+
process.on('exit', killAllChildren);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createRunner({ timeoutMs = HOOK_TIMEOUT_MS, spawnFn = spawn } = {}) {
|
|
164
|
+
let child = null;
|
|
165
|
+
let pending = null;
|
|
166
|
+
const stats = { started: 0, dropped: 0, timedOut: 0 };
|
|
167
|
+
|
|
168
|
+
function start(job) {
|
|
169
|
+
let c;
|
|
170
|
+
try {
|
|
171
|
+
c = spawnFn(job.file, job.args, { env: job.env, stdio: 'ignore', detached: true });
|
|
172
|
+
} catch {
|
|
173
|
+
return next();
|
|
174
|
+
}
|
|
175
|
+
child = c;
|
|
176
|
+
if (c.pid) track(c);
|
|
177
|
+
stats.started++;
|
|
178
|
+
// 셸이 아직 살아 있을 때만 그룹을 죽이므로 그룹 id 가 다른 프로세스 것으로 바뀌어 있을 일이 없다.
|
|
179
|
+
const timer = setTimeout(() => {
|
|
180
|
+
stats.timedOut++;
|
|
181
|
+
killGroup(c, 'SIGKILL');
|
|
182
|
+
}, timeoutMs);
|
|
183
|
+
timer.unref?.();
|
|
184
|
+
let finished = false;
|
|
185
|
+
const finish = () => {
|
|
186
|
+
if (finished) return;
|
|
187
|
+
finished = true;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
liveChildren.delete(c);
|
|
190
|
+
if (child === c) child = null;
|
|
191
|
+
next();
|
|
192
|
+
};
|
|
193
|
+
c.once('exit', finish);
|
|
194
|
+
c.once('error', finish);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function next() {
|
|
198
|
+
if (!pending) return;
|
|
199
|
+
const job = pending;
|
|
200
|
+
pending = null;
|
|
201
|
+
start(job);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
run(job) {
|
|
206
|
+
if (child) {
|
|
207
|
+
if (pending) stats.dropped++;
|
|
208
|
+
pending = job;
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
start(job);
|
|
212
|
+
return true;
|
|
213
|
+
},
|
|
214
|
+
get busy() {
|
|
215
|
+
return child != null;
|
|
216
|
+
},
|
|
217
|
+
// 대기열을 비우고 실행 중인 자식을 그룹째 SIGKILL 한다 (aitop 이 끝날 때).
|
|
218
|
+
killAll() {
|
|
219
|
+
pending = null;
|
|
220
|
+
if (child) {
|
|
221
|
+
killGroup(child, 'SIGKILL');
|
|
222
|
+
liveChildren.delete(child);
|
|
223
|
+
child = null;
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
stats,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --notify, --on-waiting 을 묶은 알리미. 새로 응답 대기가 된 세션 목록을 받는다.
|
|
231
|
+
export function createNotifier({ notify = false, onWaiting = null, notifier = notify ? findNotifier() : null, env = process.env, timeoutMs = HOOK_TIMEOUT_MS, spawnFn = spawn } = {}) {
|
|
232
|
+
const hook = onWaiting ? createRunner({ timeoutMs, spawnFn }) : null;
|
|
233
|
+
const desk = notify && notifier ? createRunner({ timeoutMs, spawnFn }) : null;
|
|
234
|
+
return {
|
|
235
|
+
hook,
|
|
236
|
+
desk,
|
|
237
|
+
killAll() {
|
|
238
|
+
hook?.killAll();
|
|
239
|
+
desk?.killAll();
|
|
240
|
+
},
|
|
241
|
+
fire(fresh) {
|
|
242
|
+
if (!fresh.length) return;
|
|
243
|
+
if (desk) {
|
|
244
|
+
const { title, body } = notifyText(fresh);
|
|
245
|
+
const [file, args] = notifierCommand(notifier, title, body);
|
|
246
|
+
desk.run({ file, args, env });
|
|
247
|
+
}
|
|
248
|
+
if (hook) for (const s of fresh) hook.run({ file: '/bin/sh', args: ['-c', onWaiting], env: { ...env, ...waitingEnv(s) } });
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
package/src/order.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// 세션 표의 정렬(s)과 묶기(g). 기본 순서는 받은 목록의 순서 그대로다 (collect.mjs 가 bySession 으로 줄 세워 넘긴다).
|
|
2
|
+
// 응답 대기 세션은 사람이 할 일이므로 어떤 정렬·묶기에서도 맨 위에 둔다.
|
|
3
|
+
// 묶을 때는 대기 세션을 프로젝트·도구 묶음에 섞지 않고 맨 위 '응답 대기' 묶음 하나로 모은다.
|
|
4
|
+
// 대기 세션이 제 프로젝트 묶음 안에 흩어지면 맨 위라는 약속이 깨지고, 묶음 머리 위에 따로 두면 어느 묶음 소속인지 헷갈린다.
|
|
5
|
+
// 고정(p)한 세션은 응답 대기 바로 아래에 고정한 순서대로 둔다. 정렬을 바꿔도 그 순서는 바뀌지 않고, 묶을 때는 '고정' 묶음 하나로 모은다.
|
|
6
|
+
// 응답 대기를 고정보다 위에 두는 것은 사람이 할 일이 고정 목록에 가려지지 않게 하려는 것이다. 고정한 세션이 응답 대기가 되면 대기 쪽으로 올라간다.
|
|
7
|
+
import { costOf } from './ui/draw.mjs';
|
|
8
|
+
|
|
9
|
+
export const SORTS = ['default', 'cost', 'context', 'recent'];
|
|
10
|
+
export const GROUPS = ['none', 'project', 'tool'];
|
|
11
|
+
export const SORT_LABEL = { default: '기본', cost: '비용', context: '컨텍스트', recent: '최근' };
|
|
12
|
+
export const GROUP_LABEL = { none: '없음', project: '프로젝트', tool: '도구' };
|
|
13
|
+
export const WAITING_GROUP = '응답 대기';
|
|
14
|
+
export const PINNED_GROUP = '고정';
|
|
15
|
+
|
|
16
|
+
// pins 에 id 가 있으면 빼고, 없으면 끝에 붙인다 (고정한 순서를 지킨다). 새 배열을 돌려준다.
|
|
17
|
+
export const togglePin = (pins = [], id) => (pins.includes(id) ? pins.filter((x) => x !== id) : [...pins, id]);
|
|
18
|
+
|
|
19
|
+
const next = (list, cur) => list[(Math.max(0, list.indexOf(cur)) + 1) % list.length];
|
|
20
|
+
export const nextSort = (sort) => next(SORTS, sort ?? 'default');
|
|
21
|
+
export const nextGroup = (group) => next(GROUPS, group ?? 'none');
|
|
22
|
+
|
|
23
|
+
const ctxPct = (s) => (s.context?.window > 0 ? (s.context.used ?? 0) / s.context.window : -1);
|
|
24
|
+
const KEYS = {
|
|
25
|
+
cost: (s, mode) => costOf(s, mode) ?? -1,
|
|
26
|
+
context: ctxPct,
|
|
27
|
+
recent: (s) => s.updatedAt ?? -Infinity,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// 종료 세션은 어떤 정렬에서도 (묶음 안에서도) 아래에 둔다. 그다음 큰 값이 위. 값이 같으면 받은 순서(기본 순서)를 지켜 갱신마다 자리가 흔들리지 않게 한다 (Array.prototype.sort 는 안정 정렬).
|
|
31
|
+
export function compare(sort, costMode = 'api') {
|
|
32
|
+
const key = KEYS[sort];
|
|
33
|
+
if (!key) return () => 0;
|
|
34
|
+
const ended = (s) => (s.status === 'ended' ? 1 : 0);
|
|
35
|
+
return (a, b) => ended(a) - ended(b) || key(b, costMode) - key(a, costMode);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const projectOf = (s) => s.project || String(s.cwd ?? '').split('/').filter(Boolean).at(-1) || '(폴더 없음)';
|
|
39
|
+
const GROUP_KEY = { project: projectOf, tool: (s) => s.tool ?? '?' };
|
|
40
|
+
|
|
41
|
+
// 돌려주는 sessions 는 화면에 그리는 순서 그대로다 (↑↓·n 도 이 순서를 따른다).
|
|
42
|
+
// groups 는 묶을 때만 있다: { name, count, cost, unknown, waiting, pinned, sessions }. 묶음은 그 안의 첫 세션이 정렬에서 앞선 순으로 놓인다.
|
|
43
|
+
// pins: 고정한 세션 id (고정한 순서). 화면에 없는 id 는 건너뛴다.
|
|
44
|
+
export function arrange(sessions, { sort = 'default', group = 'none', costMode = 'api', pins = [] } = {}) {
|
|
45
|
+
const cmp = compare(sort, costMode);
|
|
46
|
+
const rank = new Map((pins ?? []).map((id, i) => [id, i]));
|
|
47
|
+
const waiting = sessions.filter((s) => s.status === 'waiting').sort(cmp);
|
|
48
|
+
const pinned = sessions.filter((s) => s.status !== 'waiting' && rank.has(s.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id));
|
|
49
|
+
const rest = sessions.filter((s) => s.status !== 'waiting' && !rank.has(s.id)).sort(cmp);
|
|
50
|
+
const keyOf = GROUP_KEY[group];
|
|
51
|
+
if (!keyOf) return { sessions: [...waiting, ...pinned, ...rest], groups: null };
|
|
52
|
+
const byName = new Map();
|
|
53
|
+
for (const s of rest) {
|
|
54
|
+
const name = keyOf(s);
|
|
55
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
56
|
+
byName.get(name).push(s);
|
|
57
|
+
}
|
|
58
|
+
const head = [...(waiting.length ? [[WAITING_GROUP, waiting, 'waiting']] : []), ...(pinned.length ? [[PINNED_GROUP, pinned, 'pinned']] : [])];
|
|
59
|
+
const groups = [...head, ...[...byName].map(([name, list]) => [name, list, null])].map(([name, list, kind]) => ({
|
|
60
|
+
name,
|
|
61
|
+
count: list.length,
|
|
62
|
+
cost: list.reduce((a, s) => a + (costOf(s, costMode) ?? 0), 0),
|
|
63
|
+
unknown: list.some((s) => s.costUnknown),
|
|
64
|
+
waiting: kind === 'waiting',
|
|
65
|
+
pinned: kind === 'pinned',
|
|
66
|
+
sessions: list,
|
|
67
|
+
}));
|
|
68
|
+
return { sessions: groups.flatMap((g) => g.sessions), groups };
|
|
69
|
+
}
|