@kienntpixon/dieu-phoi 0.7.2 → 0.7.4

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/lib/deps.cjs ADDED
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+ // core 0.7.3 (s3): node_modules dùng chung cho worktree của dieu-phoi. OPT-IN: mặc định `none`.
3
+ // - `--deps auto|link|install|none` (hoặc env DIEU_PHOI_WT_DEPS → config.json `wt_deps` → none).
4
+ // - install (và auto khi có pnpm-lock.yaml): `pnpm install --frozen-lockfile --offline` (store hardlink, không tải mạng).
5
+ // - link (và auto khi không có pnpm-lock.yaml): junction <worktree>/<deps_path>/node_modules → node_modules của checkout
6
+ // chính, CHỈ khi package-lock.json giống hệt từng byte, package.json không có `workspaces`, và checkout chính có
7
+ // node_modules là thư mục thật. Còn lại → none + gợi ý. Không bao giờ ghi vào node_modules của checkout chính.
8
+ // - Gỡ an toàn: unlinkDepLinks gỡ junction (không đi xuyên) TRƯỚC `git worktree remove`; removeShell xoá vỏ còn sót
9
+ // bằng cách duyệt lstat, link nào cũng chỉ unlink, không bao giờ đi vào đích.
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const cp = require('child_process');
13
+
14
+ const MODES = ['auto', 'link', 'install', 'none'];
15
+ const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
16
+
17
+ function isLink(p) {
18
+ try { return fs.lstatSync(p).isSymbolicLink(); } catch (_) { return false; }
19
+ }
20
+ function isRealDir(p) {
21
+ try { const st = fs.lstatSync(p); return st.isDirectory() && !st.isSymbolicLink(); } catch (_) { return false; }
22
+ }
23
+
24
+ /** deps_path tương đối, nằm trong worktree ('.' = gốc). Ném nếu tuyệt đối hoặc thoát ra ngoài. */
25
+ function normDepsPath(p) {
26
+ if (p === undefined || p === null || p === true || p === '') return '.';
27
+ if (typeof p !== 'string' || path.isAbsolute(p)) throw new Error('--deps-path cần đường dẫn tương đối trong repo (vd. cli)');
28
+ const n = path.normalize(p).replace(/\\/g, '/').replace(/\/+$/, '') || '.';
29
+ if (n === '..' || n.startsWith('../')) throw new Error('--deps-path không được ra ngoài repo');
30
+ return n;
31
+ }
32
+
33
+ /** Chế độ: --deps → env DIEU_PHOI_WT_DEPS → config `wt_deps` → none. Giá trị hỏng từ cờ thì ném; từ env/config thì none. */
34
+ function depsModeOf(opts, ctx) {
35
+ if (opts && opts.deps !== undefined) {
36
+ if (typeof opts.deps !== 'string' || !MODES.includes(opts.deps)) throw new Error(`--deps cần một trong: ${MODES.join('|')}`);
37
+ return opts.deps;
38
+ }
39
+ const env = process.env.DIEU_PHOI_WT_DEPS;
40
+ const raw = env !== undefined && env !== '' ? env : (ctx && ctx.config ? ctx.config.wt_deps : undefined);
41
+ return typeof raw === 'string' && MODES.includes(raw) ? raw : 'none';
42
+ }
43
+
44
+ function readFileOrNull(p) {
45
+ try { return fs.readFileSync(p); } catch (_) { return null; }
46
+ }
47
+
48
+ /** Lý do không được junction (null = được). */
49
+ function linkBlocker(mainDir, wtDir) {
50
+ const wtLock = readFileOrNull(path.join(wtDir, 'package-lock.json'));
51
+ if (!wtLock) return 'worktree không có package-lock.json';
52
+ const mainLock = readFileOrNull(path.join(mainDir, 'package-lock.json'));
53
+ if (!mainLock) return 'checkout chính không có package-lock.json';
54
+ // Giống hệt từng byte, chỉ bỏ qua khác biệt CRLF/LF (core.autocrlf trên Windows checkout worktree ra CRLF trong khi
55
+ // npm ghi LF vào checkout chính; nội dung lock vẫn là một).
56
+ const lf = (b) => b.toString('utf8').replace(/\r\n/g, '\n');
57
+ if (!wtLock.equals(mainLock) && lf(wtLock) !== lf(mainLock)) return 'package-lock.json khác checkout chính';
58
+ let pkg;
59
+ try { pkg = JSON.parse(fs.readFileSync(path.join(wtDir, 'package.json'), 'utf8')); } catch (_) { return 'không đọc được package.json'; }
60
+ if (pkg && pkg.workspaces) return 'package.json có workspaces';
61
+ if (!isRealDir(path.join(mainDir, 'node_modules'))) return 'checkout chính chưa có node_modules (thư mục thật)';
62
+ if (fs.existsSync(path.join(wtDir, 'node_modules')) || isLink(path.join(wtDir, 'node_modules'))) return 'worktree đã có node_modules';
63
+ return null;
64
+ }
65
+
66
+ /** Lệnh pnpm: env DIEU_PHOI_PNPM_BIN (đường dẫn) hoặc `pnpm` trên PATH. .cmd/.bat (và `pnpm` trên Windows) cần shell. */
67
+ function pnpmSpawn(args, cwd, timeout) {
68
+ const bin = process.env.DIEU_PHOI_PNPM_BIN || 'pnpm';
69
+ const shell = process.platform === 'win32' && (!process.env.DIEU_PHOI_PNPM_BIN || /\.(cmd|bat)$/i.test(bin));
70
+ const cmd = shell && /\s/.test(bin) ? `"${bin}"` : bin;
71
+ return cp.spawnSync(cmd, args, { cwd, encoding: 'utf8', windowsHide: true, timeout, shell });
72
+ }
73
+
74
+ function pnpmInstall(wtDir) {
75
+ const v = pnpmSpawn(['--version'], wtDir, 30000);
76
+ if (v.error || v.status !== 0) return { deps: 'none', note: 'deps none: repo dùng pnpm nhưng không chạy được pnpm (vắng trên PATH?) — cài pnpm rồi chạy `pnpm install --offline` trong worktree' };
77
+ const r = pnpmSpawn(['install', '--frozen-lockfile', '--offline'], wtDir, INSTALL_TIMEOUT_MS);
78
+ if (r.error || r.status !== 0) {
79
+ const tail = String((r.stderr || '') + (r.stdout || '')).trim().split(/\r?\n/).slice(-3).join(' | ');
80
+ return { deps: 'none', note: `deps none: pnpm install --frozen-lockfile --offline lỗi (${r.error ? r.error.message : `exit ${r.status}`}${tail ? `: ${tail}` : ''}) — store thiếu gói? chạy pnpm install có mạng trong worktree` };
81
+ }
82
+ return { deps: 'pnpm', note: 'deps pnpm: đã chạy pnpm install --frozen-lockfile --offline (hardlink từ store)' };
83
+ }
84
+
85
+ /**
86
+ * Chuẩn bị node_modules cho worktree vừa tạo. Không ném: lỗi nào cũng thành deps none + ghi chú.
87
+ * Trả { deps: 'link'|'pnpm'|'none', note|null }.
88
+ */
89
+ function setupDeps({ main, wtPath, mode, depsPath }) {
90
+ if (mode === 'none') return { deps: 'none', note: null };
91
+ const rel = depsPath || '.';
92
+ const mainDir = path.join(main, rel);
93
+ const wtDir = path.join(wtPath, rel);
94
+ if (!fs.existsSync(path.join(wtDir, 'package.json'))) return { deps: 'none', note: `deps none: không có package.json ở ${rel} (dùng --deps-path <thư mục con>)` };
95
+ const pnpm = fs.existsSync(path.join(wtDir, 'pnpm-lock.yaml'));
96
+ if (pnpm && (mode === 'auto' || mode === 'install')) return pnpmInstall(wtDir);
97
+ if (pnpm) return { deps: 'none', note: 'deps none: repo dùng pnpm — dùng --deps install (hoặc auto) thay cho link' };
98
+ if (mode === 'install') return { deps: 'none', note: 'deps none: --deps install chỉ hỗ trợ pnpm (không có pnpm-lock.yaml); repo npm dùng --deps link hoặc auto' };
99
+ const why = linkBlocker(mainDir, wtDir);
100
+ if (why) return { deps: 'none', note: `deps none: không junction được (${why}) — chạy npm ci trong worktree nếu cần` };
101
+ try {
102
+ fs.symlinkSync(path.join(mainDir, 'node_modules'), path.join(wtDir, 'node_modules'), 'junction');
103
+ } catch (e) {
104
+ return { deps: 'none', note: `deps none: tạo junction lỗi (${e.message})` };
105
+ }
106
+ return { deps: 'link', note: `deps link: ${path.join(wtDir, 'node_modules')} → ${path.join(mainDir, 'node_modules')} (junction, dùng chung, không ghi vào)` };
107
+ }
108
+
109
+ /** Bỏ một link (symlink/junction) mà không đi xuyên. Ném nếu p không phải link. */
110
+ function unlinkOnly(p) {
111
+ if (!isLink(p)) throw new Error(`không phải link: ${p}`);
112
+ try { fs.unlinkSync(p); } catch (e) {
113
+ // Windows: junction/symlink thư mục đôi khi cần rmdir (RemoveDirectory chỉ bỏ reparse point, không chạm đích).
114
+ if (process.platform === 'win32') fs.rmdirSync(p); else throw e;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Gỡ mọi link node_modules của worktree (ở gốc và ở các deps_path) TRƯỚC `git worktree remove`.
120
+ * Trả [{ path, target }] để khôi phục nếu gỡ worktree thất bại. Ném nếu không gỡ được (khi đó KHÔNG được gỡ worktree).
121
+ */
122
+ function unlinkDepLinks(wtPath, depsPaths) {
123
+ const seen = new Set();
124
+ const out = [];
125
+ for (const rel of ['.', ...(depsPaths || [])]) {
126
+ const p = path.join(wtPath, rel || '.', 'node_modules');
127
+ const key = path.resolve(p);
128
+ if (seen.has(key)) continue;
129
+ seen.add(key);
130
+ if (!isLink(p)) continue;
131
+ let target = null;
132
+ try { target = fs.readlinkSync(p); } catch (_) { /* vẫn gỡ */ }
133
+ unlinkOnly(p);
134
+ if (isLink(p) || fs.existsSync(p)) throw new Error(`gỡ link ${p} không xong`);
135
+ out.push({ path: p, target });
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** Khôi phục link đã gỡ (best effort) khi `git worktree remove` thất bại. */
141
+ function relink(links) {
142
+ for (const l of links || []) {
143
+ if (!l.target || fs.existsSync(l.path) || isLink(l.path)) continue;
144
+ try { fs.symlinkSync(l.target, l.path, 'junction'); } catch (_) { /* best effort */ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Xoá vỏ thư mục worktree còn sót sau `git worktree remove` (git để lại junction/thư mục rỗng).
150
+ * Duyệt bằng lstat: link → chỉ unlink (không vào đích), file → xoá, thư mục thật → đệ quy rồi rmdir.
151
+ * Không bao giờ dùng rmSync recursive trên cây có thể chứa link.
152
+ */
153
+ function removeShell(dir) {
154
+ if (isLink(dir)) { unlinkOnly(dir); return; }
155
+ let st;
156
+ try { st = fs.lstatSync(dir); } catch (_) { return; }
157
+ if (!st.isDirectory()) { fs.unlinkSync(dir); return; }
158
+ for (const name of fs.readdirSync(dir)) removeShell(path.join(dir, name));
159
+ fs.rmdirSync(dir);
160
+ }
161
+
162
+ module.exports = { MODES, depsModeOf, normDepsPath, setupDeps, unlinkDepLinks, relink, removeShell, isLink };
package/lib/knowledge.cjs CHANGED
@@ -40,7 +40,9 @@ const KNOWLEDGE_HELP = `Cách dùng: dieu-phoi knowledge <lệnh> [--repo <đư
40
40
  thư mục). Không --from thì tự lấy kho của khoá git-common-dir nếu có
41
41
  list [--follow] [--dirty] [--json]
42
42
  các mục của kho (id, tiêu đề, số dòng, sha#rev, tags, paths) + repo liên quan (related),
43
- mỗi mục gắn ok / STALE / ? (độ tươi so với HEAD của worktree đang đứng)
43
+ mỗi mục gắn ok / STALE / drift / ? (độ tươi so với HEAD của worktree đang đứng; drift = chỉ file
44
+ file đổi nhưng không làm mục stale: glob, bản version, CHANGELOG, dòng không nêu tên mục);
45
+ cuối dòng tổng: số mục không stale
44
46
  status [--follow] [--dirty] [--no-cache] [--json]
45
47
  chỉ độ tươi: mục nào stale (file nào đổi sau sha của mục), mục nào không xác định (sha không
46
48
  có trong repo này…). Một tiến trình git (diff-tree --no-renames, pathspec = paths của các mục),
@@ -60,6 +62,8 @@ const KNOWLEDGE_HELP = `Cách dùng: dieu-phoi knowledge <lệnh> [--repo <đư
60
62
  "## +id" thêm mục (bắt buộc paths:), "## id" thay mục (bắt buộc base: <sha>#<rev>); base cũ
61
63
  → xung đột, không ghi đè; mục > 40 dòng bị từ chối. Có xung đột/từ chối → exit 2.
62
64
  Idempotent: file delta đã gộp (hash trong meta.json) và mục trùng một rev cũ trong history/ → "không đổi"
65
+ review <id> [--repo] đánh dấu mục đã xem xét: reviewed_sha = HEAD hiện tại (ghi dưới khoá kho); độ tươi chỉ tính thay đổi sau sha đó.
66
+ Mục bị thay bằng merge sẽ mất dấu này. Không đổi nội dung mục.
63
67
  bootstrap [--children <mẫu tên>[,…]] [--json]
64
68
  chỉ đọc: liệt kê nguồn để seed kho (CLAUDE.md/README/AGENTS.md của repo, context-r/contract/
65
69
  handoff cũ trong runs có nhắc repo), mục đã có, mục nên có và mẫu delta. --children: repo anh em
@@ -272,7 +276,10 @@ function sectionOut(repo, sec, body, fr) {
272
276
  const f = fr && fr.sections && fr.sections[sec.id];
273
277
  if (f) {
274
278
  out.fresh = f.state;
275
- if (f.files) { out.stale = f.files; out.stale_count = f.n_files || f.files.length; }
279
+ if (f.files) {
280
+ const k = f.state === 'drift' ? 'drift' : 'stale';
281
+ out[k] = f.files; out[`${k}_count`] = f.n_files || f.files.length;
282
+ }
276
283
  if (f.why) out.fresh_why = f.why;
277
284
  if (f.dirty) out.dirty = f.dirty;
278
285
  }
@@ -299,6 +306,7 @@ function freshnessOf(storeDir, idx, start, opts) {
299
306
 
300
307
  function freshLine(s) {
301
308
  if (s.fresh === 'stale') return `STALE — ${s.stale_count} file đổi sau sha của mục: ${core.shortFileList(s.stale, 5)} (kiểm lại trước khi dùng)`;
309
+ if (s.fresh === 'drift') return `drift — ${s.drift_count} file đổi nhưng không tính stale: ${core.shortFileList(s.drift, 5)}`;
302
310
  if (s.fresh === 'unknown') return `độ tươi: không xác định — ${s.fresh_why}`;
303
311
  if (s.fresh === 'fresh') return 'độ tươi: tươi';
304
312
  return null;
@@ -415,7 +423,7 @@ function cmdGet(opts) {
415
423
  return { code, out: parts.join('\n\n') };
416
424
  }
417
425
 
418
- const FRESH_MARK = { fresh: 'ok ', stale: 'STALE', unknown: '? ' };
426
+ const FRESH_MARK = { fresh: 'ok ', stale: 'STALE', drift: 'drift', unknown: '? ' };
419
427
 
420
428
  function listLines(idx, fr, dir) {
421
429
  return idx.sections.map((s) => {
@@ -429,7 +437,8 @@ function listLines(idx, fr, dir) {
429
437
  function freshHeader(fr, n) {
430
438
  if (!fr) return '';
431
439
  const head = fr.head ? `HEAD ${fr.head.slice(0, 7)}${fr.branch ? ` (${fr.branch})` : ''}` : 'HEAD ?';
432
- return `${head} · ${core.knowledgeSummary(fr, n)} · ${fr.cached ? 'cache' : `tính mới${fr.ms_git ? ` (git ${fr.ms_git} ms)` : ''}`}${fr.error ? ` · git lỗi: ${fr.error}` : ''}`;
440
+ const notStale = n - ((fr.counts && fr.counts.stale) || 0);
441
+ return `${head} · ${core.knowledgeSummary(fr, n)} · ${notStale} không stale · ${fr.cached ? 'cache' : `tính mới${fr.ms_git ? ` (git ${fr.ms_git} ms)` : ''}`}${fr.error ? ` · git lỗi: ${fr.error}` : ''}`;
433
442
  }
434
443
 
435
444
  /** Details under list/status: stale files, unknown reasons, uncommitted files (--dirty). */
@@ -440,6 +449,7 @@ function freshDetails(idx, fr) {
440
449
  const f = fr.sections[s.id];
441
450
  if (!f) continue;
442
451
  if (f.state === 'stale') out.push(` ${s.id}: ${f.n_files || f.files.length} file đổi — ${core.shortFileList(f.files, 5)}`);
452
+ else if (f.state === 'drift') out.push(` ${s.id}: drift (không tính stale) — ${f.n_files || f.files.length} file đổi — ${core.shortFileList(f.files, 5)}`);
443
453
  else if (f.state === 'unknown') out.push(` ${s.id}: không xác định — ${f.why}`);
444
454
  if (f.dirty) out.push(` ${s.id}: chưa commit — ${core.shortFileList(f.dirty, 5)}`);
445
455
  }
@@ -1035,6 +1045,38 @@ function cmdLink(opts) {
1035
1045
  return { code: 0, out: `${verb}: ${other.name} là ${opts.rel === 'child' ? 'con' : 'cha'} của ${info.name}${a.changed || b.changed ? '' : ' (không đổi)'}.` };
1036
1046
  }
1037
1047
 
1048
+ /**
1049
+ * Review mark (s6): records reviewed_sha = HEAD of the checkout around --repo on one section, under the store lock.
1050
+ * freshness then counts only the changes after that sha. Body, paths and base are untouched; a merge that replaces the
1051
+ * section writes a new entry without the mark, so new content is never shown as reviewed.
1052
+ */
1053
+ function cmdReview(opts) {
1054
+ const { start, info } = infoOf(opts, false);
1055
+ if (!info) return notRepo(start);
1056
+ const ids = opts._.map(String).filter((t) => t.trim());
1057
+ if (ids.length !== 1 || !ID_RE.test(ids[0])) return { code: 1, out: `Cần đúng một <id> mục.\n\n${KNOWLEDGE_HELP.trimEnd()}` };
1058
+ const id = ids[0];
1059
+ const g = core.findGit(start);
1060
+ const h = g ? core.readHead(g) : null;
1061
+ const sha = h && h.sha ? h.sha : (h && h.unsupported ? headShaOf(start) : null);
1062
+ if (!sha || !/^[0-9a-f]{40,64}$/i.test(sha)) return { code: 1, out: 'Không đọc được HEAD đầy đủ của repo (chưa có commit?).' };
1063
+ const ri = readIndex(info.dir);
1064
+ if (ri.error) return { code: 1, out: ri.error };
1065
+ if (!ri.exists) return { code: 1, out: `Kho chưa có mục nào: ${info.dir}.` };
1066
+ const lk = withLock(path.join(info.dir, 'knowledge.lock'), () => {
1067
+ const cur = readIndex(info.dir);
1068
+ if (cur.error) return { code: 1, out: cur.error };
1069
+ const at = cur.idx.sections.findIndex((s) => s.id === id);
1070
+ if (at < 0) return { code: 1, out: `Chưa có mục ${id} trong kho ${info.name}.` };
1071
+ const prev = cur.idx.sections[at].reviewed_sha || null;
1072
+ cur.idx.sections[at] = Object.assign({}, cur.idx.sections[at], { reviewed_sha: sha.toLowerCase(), reviewed_at: new Date().toISOString() });
1073
+ core.writeFileAtomic(path.join(info.dir, 'index.json'), `${JSON.stringify(cur.idx, null, 2)}\n`);
1074
+ return { code: 0, out: `review ${id}: reviewed_sha ${shortSha(sha)}${prev ? ` (trước: ${shortSha(prev)})` : ''}` };
1075
+ }, LOCK_TRIES);
1076
+ if (lk.locked) return { code: 1, out: 'Kho đang bị khoá (knowledge.lock), thử lại.' };
1077
+ return lk.value;
1078
+ }
1079
+
1038
1080
  function cmdKnowledge(opts) {
1039
1081
  const sub = opts._.shift();
1040
1082
  if (!sub || opts.help || opts.h || sub === 'help') {
@@ -1045,6 +1087,7 @@ function cmdKnowledge(opts) {
1045
1087
  if (sub === 'get') return cmdGet(opts);
1046
1088
  if (sub === 'list') return cmdList(opts);
1047
1089
  if (sub === 'status') return cmdStatus(opts);
1090
+ if (sub === 'review') return cmdReview(opts);
1048
1091
  if (sub === 'merge') return cmdMerge(opts);
1049
1092
  if (sub === 'link') return cmdLink(opts);
1050
1093
  if (sub === 'bootstrap') return cmdBootstrap(opts);
package/lib/load.cjs ADDED
@@ -0,0 +1,332 @@
1
+ 'use strict';
2
+ // core 0.7.3 (s5): đo tải để kiểm giả thuyết (contract jiptvo5g, mục 1) — số agent đồng thời và IO đĩa.
3
+ // dieu-phoi load sample [--interval 10] [--count 12] [--repo R] [--json] → data/load.jsonl, một mẫu một dòng
4
+ // dieu-phoi load report [--days 21] [--json] → phút ở mỗi mức đồng thời + tương quan IO
5
+ // Mẫu chỉ chứa số đếm và tốc độ IO: không có đường dẫn, prompt hay nội dung task (log không lộ nội dung).
6
+ // Hook SubagentStart ghi thêm mẫu chỉ-số-đếm (không IO) qua hook.cjs appendLoadSample.
7
+
8
+ const fs = require('fs');
9
+ const cp = require('child_process');
10
+ const core = require('../hook.cjs');
11
+ const args = require('./args.cjs');
12
+ const { mainCheckoutOf, markedWorktrees } = require('./worktree.cjs');
13
+
14
+ const DISK_READ = '\\PhysicalDisk(_Total)\\Disk Read Bytes/sec';
15
+ const DISK_WRITE = '\\PhysicalDisk(_Total)\\Disk Write Bytes/sec';
16
+ const PROC_DISKSTATS = '/proc/diskstats';
17
+ const SECTOR_BYTES = 512;
18
+ const DISK_WINDOW_MS = 1000;
19
+ /** Dispatch chưa có complete: tính tối đa bằng này (= stuck_hard_min mặc định của s6). */
20
+ const OPEN_CAP_MS = 120 * 60 * 1000;
21
+ const MIN_MS = 60 * 1000;
22
+ const WHOLE_DISK_RE = /^(sd[a-z]+|vd[a-z]+|xvd[a-z]+|hd[a-z]+|nvme\d+n\d+|mmcblk\d+)$/;
23
+ const MB = 1024 * 1024;
24
+
25
+ function sleepSync(ms) {
26
+ if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
27
+ }
28
+
29
+ function num(s) {
30
+ const t = String(s === undefined || s === null ? '' : s).trim();
31
+ return t === '' ? NaN : Number(t);
32
+ }
33
+
34
+ /** typeperf CSV: the last data row ("<date>","<read>","<write>") → {read, write}, or null. */
35
+ function parseTypeperf(text) {
36
+ let row = null;
37
+ for (const line of String(text || '').split(/\r?\n/)) {
38
+ const m = /^"(\d[^"]*)","([^"]*)","([^"]*)"$/.exec(line.trim());
39
+ if (m) row = m;
40
+ }
41
+ if (!row) return null;
42
+ const read = num(row[2]);
43
+ const write = num(row[3]);
44
+ if (!Number.isFinite(read) || !Number.isFinite(write)) return null;
45
+ return { read, write };
46
+ }
47
+
48
+ /** /proc/diskstats → byte totals over whole disks only (partitions, loop and ram devices skipped), or null. */
49
+ function parseDiskstats(text) {
50
+ let read = 0;
51
+ let write = 0;
52
+ let any = false;
53
+ for (const line of String(text || '').split('\n')) {
54
+ const p = line.trim().split(/\s+/);
55
+ if (p.length < 10 || !WHOLE_DISK_RE.test(p[2])) continue;
56
+ read += Number(p[5]) * SECTOR_BYTES;
57
+ write += Number(p[9]) * SECTOR_BYTES;
58
+ any = true;
59
+ }
60
+ return any ? { read, write } : null;
61
+ }
62
+
63
+ function typeperfRates() {
64
+ const out = cp.execFileSync('typeperf', [DISK_READ, DISK_WRITE, '-sc', '1'], {
65
+ encoding: 'utf8', windowsHide: true, timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'],
66
+ });
67
+ return parseTypeperf(out);
68
+ }
69
+
70
+ function readDiskstats() {
71
+ try { return parseDiskstats(fs.readFileSync(PROC_DISKSTATS, 'utf8')); } catch (_) { return null; }
72
+ }
73
+
74
+ /**
75
+ * Bytes/s over one window. Windows: typeperf samples the window itself. POSIX: /proc/diskstats read twice around a
76
+ * DISK_WINDOW_MS sleep. Anything missing → null (never throws). deps lets the tests inject the platform and readers.
77
+ */
78
+ function diskRates(deps) {
79
+ const d = deps || {};
80
+ const platform = d.platform || process.platform;
81
+ let r = null;
82
+ try {
83
+ if (platform === 'win32') {
84
+ r = (d.typeperf || typeperfRates)();
85
+ } else {
86
+ const read = d.readDiskstats || readDiskstats;
87
+ const a = read();
88
+ (d.sleep || sleepSync)(DISK_WINDOW_MS);
89
+ const b = read();
90
+ if (a && b) {
91
+ const secs = DISK_WINDOW_MS / 1000;
92
+ r = { read: (b.read - a.read) / secs, write: (b.write - a.write) / secs };
93
+ }
94
+ }
95
+ } catch (_) {
96
+ r = null;
97
+ }
98
+ if (!r) return { disk_read_Bps: null, disk_write_Bps: null };
99
+ return {
100
+ disk_read_Bps: Math.max(0, Math.round(r.read)),
101
+ disk_write_Bps: Math.max(0, Math.round(r.write)),
102
+ };
103
+ }
104
+
105
+ /** Marked worktrees that still exist in the repo at `repoDir`, or null when it is not a git checkout. */
106
+ function markedCount(repoDir) {
107
+ try {
108
+ const { common } = mainCheckoutOf(repoDir);
109
+ return markedWorktrees(common).filter((w) => w.exists).length;
110
+ } catch (_) {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** One sample: counts (liveLoadCounts) + IO rates + marked worktrees. */
116
+ function collectSample(ctx, o) {
117
+ const now = Date.now();
118
+ const counts = core.liveLoadCounts(ctx, now);
119
+ const io = diskRates(o.deps);
120
+ return Object.assign({ ts: new Date(now).toISOString(), src: 'sample' }, counts,
121
+ { worktrees_marked: markedCount(o.repo) }, io);
122
+ }
123
+
124
+ /** Takes `count` samples `interval` seconds apart (start to start) and appends each to data/load.jsonl. */
125
+ function sampleLoop(ctx, o) {
126
+ const file = core.loadFile(ctx);
127
+ fs.mkdirSync(ctx.paths.data, { recursive: true });
128
+ const recs = [];
129
+ for (let i = 0; i < o.count; i++) {
130
+ const t0 = Date.now();
131
+ const rec = collectSample(ctx, o);
132
+ fs.appendFileSync(file, `${JSON.stringify(rec)}\n`);
133
+ recs.push(rec);
134
+ if (i < o.count - 1) sleepSync(Math.max(0, o.interval * 1000 - (Date.now() - t0)));
135
+ }
136
+ return recs;
137
+ }
138
+
139
+ function numArg(v, dflt, name, min, max) {
140
+ if (v === undefined) return dflt;
141
+ const n = num(v);
142
+ if (!Number.isFinite(n) || n < min || n > max) throw new Error(`--${name} phải là số trong [${min}, ${max}]`);
143
+ return n;
144
+ }
145
+
146
+ function mbps(v) {
147
+ return Number.isFinite(v) ? `${(v / MB).toFixed(2)} MB/s` : 'n/a';
148
+ }
149
+
150
+ function cmdLoadSample(opts, deps) {
151
+ const ctx = args.ctxOf(opts);
152
+ const count = Math.floor(numArg(opts.count, 12, 'count', 1, 100000));
153
+ const interval = numArg(opts.interval, 10, 'interval', 0, 86400);
154
+ const repo = args.repoOf(opts);
155
+ const recs = sampleLoop(ctx, { count, interval, repo, deps });
156
+ if (opts.json) return { code: 0, out: JSON.stringify(recs, null, 2) };
157
+ const lines = recs.map((r) => ` ${r.ts} · máy ${r.live_machine} · hàng đợi ${r.queue} · đọc ${mbps(r.disk_read_Bps)} · ghi ${mbps(r.disk_write_Bps)}`);
158
+ return { code: 0, out: `dieu-phoi load sample: ${recs.length} mẫu → ${core.loadFile(ctx)}\n${lines.join('\n')}` };
159
+ }
160
+
161
+ /** Dispatch/complete events → intervals. A complete closes the oldest open dispatch of the same session+task+role. */
162
+ function pairIntervals(events, nowMs) {
163
+ const sorted = events.slice().sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
164
+ const open = new Map();
165
+ const out = [];
166
+ for (const e of sorted) {
167
+ const t = Date.parse(e.ts);
168
+ const key = `${e.session || ''}|${e.task_id || ''}|${e.role || ''}`;
169
+ if (e.kind === 'dispatch') {
170
+ if (!open.has(key)) open.set(key, []);
171
+ open.get(key).push({ t, session: e.session || '-' });
172
+ } else {
173
+ const q = open.get(key);
174
+ if (q && q.length) {
175
+ const s = q.shift();
176
+ out.push({ s: s.t, e: t, session: s.session, paired: true });
177
+ }
178
+ }
179
+ }
180
+ let unpaired = 0;
181
+ for (const q of open.values()) {
182
+ for (const s of q) {
183
+ unpaired += 1;
184
+ out.push({ s: s.t, e: Math.min(s.t + OPEN_CAP_MS, nowMs), session: s.session, paired: false });
185
+ }
186
+ }
187
+ return { intervals: out, unpaired };
188
+ }
189
+
190
+ function addMs(map, level, ms) {
191
+ map.set(level, (map.get(level) || 0) + ms);
192
+ }
193
+
194
+ /** Milliseconds at each concurrency level: machine (every interval) and busiest session (max over sessions). */
195
+ function levelTimes(intervals) {
196
+ const marks = [];
197
+ for (const iv of intervals) {
198
+ marks.push({ t: iv.s, d: 1, sess: iv.session });
199
+ marks.push({ t: iv.e, d: -1, sess: iv.session });
200
+ }
201
+ marks.sort((a, b) => (a.t - b.t) || (a.d - b.d)); // ends before starts at the same instant
202
+ const machine = new Map();
203
+ const busiest = new Map();
204
+ const perSession = new Map();
205
+ let cur = 0;
206
+ let maxS = 0;
207
+ let last = null;
208
+ for (const m of marks) {
209
+ if (last !== null && m.t > last) {
210
+ if (cur > 0) addMs(machine, cur, m.t - last);
211
+ if (maxS > 0) addMs(busiest, maxS, m.t - last);
212
+ }
213
+ last = m.t;
214
+ cur += m.d;
215
+ perSession.set(m.sess, (perSession.get(m.sess) || 0) + m.d);
216
+ maxS = 0;
217
+ for (const v of perSession.values()) if (v > maxS) maxS = v;
218
+ }
219
+ return { machine, busiest };
220
+ }
221
+
222
+ function pearson(xs, ys) {
223
+ const n = xs.length;
224
+ if (n < 3) return null;
225
+ const mx = xs.reduce((a, b) => a + b, 0) / n;
226
+ const my = ys.reduce((a, b) => a + b, 0) / n;
227
+ let cov = 0;
228
+ let vx = 0;
229
+ let vy = 0;
230
+ for (let i = 0; i < n; i++) {
231
+ cov += (xs[i] - mx) * (ys[i] - my);
232
+ vx += (xs[i] - mx) ** 2;
233
+ vy += (ys[i] - my) ** 2;
234
+ }
235
+ if (vx === 0 || vy === 0) return null;
236
+ return cov / Math.sqrt(vx * vy);
237
+ }
238
+
239
+ function readLoadSamples(ctx, sinceMs) {
240
+ let text = '';
241
+ try { text = fs.readFileSync(core.loadFile(ctx), 'utf8'); } catch (_) { return []; }
242
+ const out = [];
243
+ for (const line of text.split('\n')) {
244
+ if (!line.trim()) continue;
245
+ let r = null;
246
+ try { r = JSON.parse(line); } catch (_) { continue; }
247
+ if (r && Date.parse(r.ts) >= sinceMs) out.push(r);
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /** Correlation of disk read with the machine's live agent count, on samples that carry both. */
253
+ function ioCorrelation(samples) {
254
+ const rows = samples.filter((r) => Number.isFinite(r.live_machine) && Number.isFinite(r.disk_read_Bps));
255
+ const byLevel = new Map();
256
+ for (const r of rows) {
257
+ const g = byLevel.get(r.live_machine) || { n: 0, read: 0, write: 0, writeN: 0 };
258
+ g.n += 1;
259
+ g.read += r.disk_read_Bps;
260
+ if (Number.isFinite(r.disk_write_Bps)) { g.write += r.disk_write_Bps; g.writeN += 1; }
261
+ byLevel.set(r.live_machine, g);
262
+ }
263
+ const levels = [...byLevel.entries()].sort((a, b) => a[0] - b[0]).map(([level, g]) => ({
264
+ live_machine: level,
265
+ samples: g.n,
266
+ mean_read_Bps: Math.round(g.read / g.n),
267
+ mean_write_Bps: g.writeN ? Math.round(g.write / g.writeN) : null,
268
+ }));
269
+ return {
270
+ samples: rows.length,
271
+ r: pearson(rows.map((r) => r.live_machine), rows.map((r) => r.disk_read_Bps)),
272
+ levels,
273
+ };
274
+ }
275
+
276
+ function cmdLoadReport(opts) {
277
+ const ctx = args.ctxOf(opts);
278
+ const days = numArg(opts.days, 21, 'days', 0.01, 3650);
279
+ const now = Date.now();
280
+ const since = now - days * 86400000;
281
+ const events = core.readEvents(ctx).filter((e) => e && (e.kind === 'dispatch' || e.kind === 'complete')
282
+ && Date.parse(e.ts) >= since);
283
+ const { intervals, unpaired } = pairIntervals(events, now);
284
+ const { machine, busiest } = levelTimes(intervals);
285
+ const toMin = (ms) => Math.round(ms / MIN_MS * 10) / 10;
286
+ const levels = new Map();
287
+ for (const k of new Set([...machine.keys(), ...busiest.keys()])) {
288
+ levels.set(k, { machine_min: toMin(machine.get(k) || 0), busiest_session_min: toMin(busiest.get(k) || 0) });
289
+ }
290
+ const atLeast = (m, n) => toMin([...m.entries()].filter(([k]) => k >= n).reduce((a, [, v]) => a + v, 0));
291
+ const samples = readLoadSamples(ctx, since);
292
+ const corr = ioCorrelation(samples);
293
+ const summary = {
294
+ days,
295
+ pairs: intervals.filter((i) => i.paired).length,
296
+ unpaired,
297
+ machine_min_at_least: { 7: atLeast(machine, 7), 9: atLeast(machine, 9) },
298
+ session_min_at_least: { 4: atLeast(busiest, 4) },
299
+ levels: [...levels.entries()].sort((a, b) => a[0] - b[0]).map(([level, v]) => Object.assign({ level }, v)),
300
+ load: { samples: samples.length, io_samples: corr.samples, r: corr.r === null ? null : Math.round(corr.r * 100) / 100, by_machine: corr.levels },
301
+ };
302
+ if (opts.json) return { code: 0, out: JSON.stringify(summary, null, 2) };
303
+ return { code: 0, out: formatReport(summary) };
304
+ }
305
+
306
+ function formatReport(s) {
307
+ const head = `dieu-phoi load report — ${s.days} ngày · ${s.pairs} agent có cả dispatch và complete · ${s.unpaired} dispatch chưa đóng (tính tối đa 120 phút)`;
308
+ const rows = ['Đồng thời | toàn máy (phút) | phiên lớn nhất (phút)', '---|---|---'];
309
+ for (const l of s.levels) rows.push(`${l.level} | ${l.machine_min} | ${l.busiest_session_min}`);
310
+ if (!s.levels.length) rows.push('(không có agent trong khoảng này)');
311
+ const mark = `Mốc: toàn máy ≥7 agent ${s.machine_min_at_least[7]} phút · toàn máy ≥9 ${s.machine_min_at_least[9]} phút · một phiên ≥4 ${s.session_min_at_least[4]} phút`;
312
+ const ld = s.load;
313
+ const corrLine = ld.io_samples < 3
314
+ ? `Tải IO: ${ld.samples} mẫu trong data/load.jsonl, ${ld.io_samples} có IO đĩa — chưa đủ để tương quan (chạy \`dieu-phoi load sample\`).`
315
+ : `Tải IO: ${ld.io_samples} mẫu có IO · tương quan r(agent toàn máy, đọc đĩa) = ${ld.r === null ? 'n/a' : ld.r} (chỉ báo: IO là của cả máy, không riêng agent).`;
316
+ const lvl = ld.by_machine.map((g) => `${g.live_machine} | ${g.samples} | ${(g.mean_read_Bps / MB).toFixed(2)} | ${g.mean_write_Bps === null ? 'n/a' : (g.mean_write_Bps / MB).toFixed(2)}`);
317
+ const lvlTable = lvl.length ? ['Agent toàn máy | mẫu | đọc TB (MB/s) | ghi TB (MB/s)', '---|---|---|---', ...lvl] : [];
318
+ return [head, '', ...rows, '', mark, '', corrLine, ...lvlTable].join('\n');
319
+ }
320
+
321
+ function cmdLoad(opts) {
322
+ const sub = opts._[0];
323
+ if (sub === 'sample') return cmdLoadSample(opts);
324
+ if (sub === 'report') return cmdLoadReport(opts);
325
+ throw new Error('Cú pháp: dieu-phoi load sample [--interval 10] [--count 12] [--repo R] [--json]\n'
326
+ + ' dieu-phoi load report [--days 21] [--json]');
327
+ }
328
+
329
+ module.exports = {
330
+ cmdLoad, cmdLoadSample, cmdLoadReport, parseTypeperf, parseDiskstats, diskRates, collectSample, sampleLoop,
331
+ pairIntervals, levelTimes, pearson, ioCorrelation, readLoadSamples, DISK_READ, DISK_WRITE, OPEN_CAP_MS,
332
+ };