@nocville/npc 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -120
  3. package/dist/cjs/cli.js +55 -34
  4. package/dist/cjs/core/base-npc.js +8 -0
  5. package/dist/cjs/core/config.js +19 -2
  6. package/dist/cjs/core/errors.js +23 -0
  7. package/dist/cjs/core/http.js +121 -0
  8. package/dist/cjs/core/pixel-art.js +110 -0
  9. package/dist/cjs/core/polling-npc.js +99 -14
  10. package/dist/cjs/core/session-filter.js +3 -1
  11. package/dist/cjs/core/text.js +13 -0
  12. package/dist/cjs/core/thresholds.js +29 -0
  13. package/dist/cjs/index.js +10 -2
  14. package/dist/cjs/npcs/agent-common/session.js +1 -0
  15. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  16. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  17. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  18. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  19. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  20. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  21. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  22. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  23. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  24. package/dist/cjs/npcs/process-watcher/index.js +61 -51
  25. package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
  26. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  27. package/dist/cjs/npcs/receptionist/index.js +45 -19
  28. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  29. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  30. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  31. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  32. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  33. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  34. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  35. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  36. package/dist/cli.js +56 -35
  37. package/dist/core/base-npc.d.ts +5 -0
  38. package/dist/core/base-npc.js +8 -0
  39. package/dist/core/config.d.ts +5 -0
  40. package/dist/core/config.js +18 -2
  41. package/dist/core/errors.d.ts +11 -0
  42. package/dist/core/errors.js +18 -0
  43. package/dist/core/http.d.ts +52 -0
  44. package/dist/core/http.js +111 -0
  45. package/dist/core/pixel-art.d.ts +38 -0
  46. package/dist/core/pixel-art.js +105 -0
  47. package/dist/core/polling-npc.d.ts +22 -1
  48. package/dist/core/polling-npc.js +98 -13
  49. package/dist/core/session-filter.d.ts +2 -2
  50. package/dist/core/session-filter.js +3 -1
  51. package/dist/core/text.d.ts +6 -0
  52. package/dist/core/text.js +12 -0
  53. package/dist/core/thresholds.d.ts +9 -1
  54. package/dist/core/thresholds.js +27 -0
  55. package/dist/index.d.ts +5 -2
  56. package/dist/index.js +4 -2
  57. package/dist/npcs/agent-common/session.js +1 -0
  58. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  59. package/dist/npcs/github-watcher/defaults.js +7 -1
  60. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  61. package/dist/npcs/github-watcher/github-api.js +80 -45
  62. package/dist/npcs/github-watcher/index.d.ts +6 -0
  63. package/dist/npcs/github-watcher/index.js +83 -37
  64. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  65. package/dist/npcs/internet-connectivity/checks.js +70 -67
  66. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  67. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  68. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  69. package/dist/npcs/internet-connectivity/index.js +135 -133
  70. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  71. package/dist/npcs/log-watcher/buffer.js +58 -0
  72. package/dist/npcs/log-watcher/index.d.ts +22 -21
  73. package/dist/npcs/log-watcher/index.js +112 -106
  74. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  75. package/dist/npcs/log-watcher/tail.js +101 -54
  76. package/dist/npcs/process-watcher/index.d.ts +1 -1
  77. package/dist/npcs/process-watcher/index.js +62 -52
  78. package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
  79. package/dist/npcs/process-watcher/process-scanner.js +61 -71
  80. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  81. package/dist/npcs/receptionist/defaults.js +4 -0
  82. package/dist/npcs/receptionist/index.d.ts +12 -2
  83. package/dist/npcs/receptionist/index.js +46 -20
  84. package/dist/npcs/receptionist/weather.d.ts +8 -6
  85. package/dist/npcs/receptionist/weather.js +39 -45
  86. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  87. package/dist/npcs/system-monitor/collectors.js +64 -17
  88. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  89. package/dist/npcs/system-monitor/index.d.ts +2 -1
  90. package/dist/npcs/system-monitor/index.js +54 -30
  91. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  92. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  93. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  94. package/dist/npcs/web-page-watcher/capture.js +21 -1
  95. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  96. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  97. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  98. package/dist/npcs/web-page-watcher/index.js +121 -70
  99. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  100. package/package.json +2 -7
@@ -1,49 +1,87 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LogWatcherNPC = void 0;
4
+ const node_fs_1 = require("node:fs");
4
5
  const node_path_1 = require("node:path");
5
- const base_npc_js_1 = require("../../core/base-npc.js");
6
+ const polling_npc_js_1 = require("../../core/polling-npc.js");
6
7
  const thresholds_js_1 = require("../../core/thresholds.js");
7
- const text_js_1 = require("../../core/text.js");
8
+ const errors_js_1 = require("../../core/errors.js");
8
9
  const tail_js_1 = require("./tail.js");
9
10
  const buffer_js_1 = require("./buffer.js");
10
11
  const defaults_js_1 = require("./defaults.js");
11
- class LogWatcherNPC extends base_npc_js_1.BaseNPC {
12
+ /** Lines shown on the log terminal, and their width. */
13
+ const SCREEN_LINES = 12;
14
+ const SCREEN_LINE_CHARS = 80;
15
+ function compilePattern(flag, source) {
16
+ try {
17
+ return new RegExp(source);
18
+ }
19
+ catch (err) {
20
+ throw new errors_js_1.NpcConfigError(`${flag}: invalid regular expression (${err instanceof Error ? err.message : String(err)})`);
21
+ }
22
+ }
23
+ /** "45s", "5min", "1.5h" */
24
+ function formatWindow(seconds) {
25
+ if (seconds < 60)
26
+ return `${seconds}s`;
27
+ if (seconds < 3600)
28
+ return `${Math.round((seconds / 60) * 10) / 10}min`;
29
+ return `${Math.round((seconds / 3600) * 10) / 10}h`;
30
+ }
31
+ /**
32
+ * Tails log files and reports error bursts. A periodic report (every
33
+ * `reportInterval`) carries the error count over the sliding window and the
34
+ * last lines for the log terminal; a fresh error line also triggers an
35
+ * immediate report, debounced to one per 5s, so a burst shows up at once.
36
+ * Every report is one composed push, never a push per line.
37
+ */
38
+ class LogWatcherNPC extends polling_npc_js_1.PollingNPC {
12
39
  tailers = [];
13
- timer = null;
14
- entries = [];
40
+ counter;
15
41
  recentLines = [];
16
- lastBurstPush = 0;
42
+ lastError = null;
43
+ /** The error line that triggered an immediate report, consumed by the next collect(). */
44
+ burstLine = null;
45
+ lastBurstAt = 0;
17
46
  errorRegex;
18
47
  warningRegex;
19
48
  windowMs;
20
- errorWarn;
21
- errorCrit;
22
- reportInterval;
49
+ errorThreshold;
23
50
  files;
24
51
  constructor(config) {
25
- super(config);
26
- this.files = config.files;
27
- this.errorRegex = new RegExp(config.errorPattern ?? defaults_js_1.DEFAULTS.errorPattern);
28
- this.warningRegex = new RegExp(config.warningPattern ?? defaults_js_1.DEFAULTS.warningPattern);
29
- this.windowMs = (config.windowSeconds ?? defaults_js_1.DEFAULTS.windowSeconds) * 1000;
30
- this.errorWarn = config.errorWarn ?? defaults_js_1.DEFAULTS.errorWarn;
31
- this.errorCrit = config.errorCrit ?? defaults_js_1.DEFAULTS.errorCrit;
32
- this.reportInterval = config.reportInterval ?? defaults_js_1.DEFAULTS.reportInterval;
52
+ super({
53
+ ...config,
54
+ pollInterval: config.reportInterval ?? config.pollInterval ?? defaults_js_1.DEFAULTS.reportInterval,
55
+ });
56
+ this.files = [...new Set(config.files.map((f) => f.trim()).filter(Boolean))].map((f) => (0, node_path_1.resolve)(f));
57
+ if (this.files.length === 0) {
58
+ throw new errors_js_1.NpcConfigError('log-watcher needs at least one file (--files)');
59
+ }
60
+ this.errorRegex = compilePattern('--error-pattern', config.errorPattern ?? defaults_js_1.DEFAULTS.errorPattern);
61
+ this.warningRegex = compilePattern('--warning-pattern', config.warningPattern ?? defaults_js_1.DEFAULTS.warningPattern);
62
+ const windowSeconds = config.windowSeconds ?? defaults_js_1.DEFAULTS.windowSeconds;
63
+ if (!(windowSeconds > 0))
64
+ throw new errors_js_1.NpcConfigError('--window must be a positive number of seconds');
65
+ this.windowMs = windowSeconds * 1000;
66
+ this.counter = new buffer_js_1.WindowCounter(this.windowMs);
67
+ this.errorThreshold = {
68
+ warning: config.errorWarn ?? defaults_js_1.DEFAULTS.errorWarn,
69
+ critical: config.errorCrit ?? defaults_js_1.DEFAULTS.errorCrit,
70
+ direction: 'above',
71
+ };
72
+ const problem = (0, thresholds_js_1.thresholdProblem)('--error-warn/--error-crit', this.errorThreshold);
73
+ if (problem)
74
+ throw new errors_js_1.NpcConfigError(problem);
33
75
  }
34
76
  get name() {
35
77
  return 'log-watcher';
36
78
  }
37
79
  get defaultDisplayName() {
38
- const first = this.files[0];
39
- return first ? `Logs: ${(0, node_path_1.basename)(first)}` : 'Logs';
80
+ return `Logs: ${(0, node_path_1.basename)(this.files[0])}`;
40
81
  }
41
82
  get archetype() {
42
83
  return 'service';
43
84
  }
44
- get heartbeatSeconds() {
45
- return 30;
46
- }
47
85
  get behavior() {
48
86
  return { idle: 'wander' };
49
87
  }
@@ -52,109 +90,77 @@ class LogWatcherNPC extends base_npc_js_1.BaseNPC {
52
90
  screenType: 'log-terminal',
53
91
  size: { w: 2, h: 2 },
54
92
  placement: 'adjacent-wall',
55
- label: this.files[0] ? (0, node_path_1.basename)(this.files[0]) : 'log',
93
+ label: (0, node_path_1.basename)(this.files[0]),
56
94
  };
57
95
  }
58
96
  async onStart() {
59
97
  for (const filePath of this.files) {
60
- this.logger.info(`Tailing ${filePath}`);
61
- const tailer = (0, tail_js_1.createTailer)(filePath, (line) => {
62
- this.handleLine(line);
63
- });
64
- this.tailers.push(tailer);
98
+ if ((0, node_fs_1.existsSync)(filePath)) {
99
+ this.logger.info(`Tailing ${filePath}`);
100
+ }
101
+ else {
102
+ this.logger.warn(`${filePath} does not exist yet; will tail it once it appears`);
103
+ }
104
+ this.tailers.push((0, tail_js_1.createTailer)(filePath, (line) => this.handleLine(line), {
105
+ onError: (err) => this.logger.warn(`Cannot read ${filePath}`, { error: err.message }),
106
+ }));
65
107
  }
66
- // Report immediately, then on interval
67
- await this.report();
68
- this.timer = setInterval(() => {
69
- void this.report();
70
- }, this.reportInterval);
108
+ await super.onStart();
71
109
  }
72
110
  async onStop() {
73
- if (this.timer) {
74
- clearInterval(this.timer);
75
- this.timer = null;
76
- }
77
- for (const tailer of this.tailers) {
111
+ for (const tailer of this.tailers)
78
112
  tailer.close();
79
- }
80
113
  this.tailers = [];
114
+ await super.onStop();
81
115
  }
82
- handleLine(line) {
83
- const now = Date.now();
116
+ /** Visible for tests: feed one raw line as if a tailer had read it. */
117
+ handleLine(line, now = Date.now()) {
84
118
  const level = (0, buffer_js_1.classifyLine)(line, this.errorRegex, this.warningRegex);
85
- // Ring buffer holds ALL lines (matched ones flagged) for the log TV.
86
- (0, buffer_js_1.pushRecent)(this.recentLines, line, level);
87
- if (level === 'error') {
88
- this.entries.push({ timestamp: now, type: 'error', message: line });
89
- this.onErrorBurst(line);
90
- }
91
- else if (level === 'warning') {
92
- this.entries.push({ timestamp: now, type: 'warning', message: line });
93
- }
94
- }
95
- /** A fresh error triggers an immediate push, debounced to at most once / 5s. */
96
- onErrorBurst(line) {
97
- const now = Date.now();
98
- if (!(0, buffer_js_1.shouldEmitBurst)(this.lastBurstPush, now))
119
+ // The ring buffer holds ALL lines (matched ones flagged) for the log TV.
120
+ (0, buffer_js_1.pushRecent)(this.recentLines, (0, buffer_js_1.cleanLogLine)(line, SCREEN_LINE_CHARS), level, SCREEN_LINES);
121
+ if (level === 'info')
99
122
  return;
100
- this.lastBurstPush = now;
101
- void this.pushReport((0, text_js_1.truncate)(line, 200), 'error');
102
- }
103
- pruneWindow() {
104
- const cutoff = Date.now() - this.windowMs;
105
- this.entries = this.entries.filter((e) => e.timestamp >= cutoff);
106
- }
107
- async report() {
108
- if (this.state !== 'running')
123
+ this.counter.add(level, now);
124
+ if (level !== 'error')
109
125
  return;
110
- await this.pushReport();
126
+ const text = (0, buffer_js_1.cleanLogLine)(line);
127
+ this.lastError = { text, at: now };
128
+ if ((0, buffer_js_1.shouldEmitBurst)(this.lastBurstAt, now)) {
129
+ this.lastBurstAt = now;
130
+ this.burstLine = text;
131
+ this.collectNow();
132
+ }
111
133
  }
112
- /**
113
- * Build and push the current status. If `overrideMessage` is set (burst push),
114
- * it drives the bubble and the status floors at warning.
115
- */
116
- async pushReport(overrideMessage, subStatus) {
117
- if (this.state !== 'running')
118
- return;
119
- this.pruneWindow();
120
- const errorCount = this.entries.filter((e) => e.type === 'error').length;
121
- const warningCount = this.entries.filter((e) => e.type === 'warning').length;
122
- const windowSeconds = this.windowMs / 1000;
123
- let status = (0, thresholds_js_1.evaluateThreshold)(errorCount, {
124
- warning: this.errorWarn,
125
- critical: this.errorCrit,
126
- direction: 'above',
127
- });
128
- if (overrideMessage)
134
+ async collect() {
135
+ const now = Date.now();
136
+ const { errors, warnings } = this.counter.counts(now);
137
+ const window = formatWindow(this.windowMs / 1000);
138
+ const burst = this.burstLine;
139
+ this.burstLine = null;
140
+ let status = (0, thresholds_js_1.evaluateThreshold)(errors, this.errorThreshold);
141
+ // A burst report drives the bubble with the error itself and floors at warning.
142
+ if (burst)
129
143
  status = (0, thresholds_js_1.worstStatus)([status, 'warning']);
130
- const mood = status === 'critical' ? 'stressed' : status === 'warning' ? 'concerned' : 'happy';
131
- const lastError = this.entries.filter((e) => e.type === 'error').at(-1);
132
- const message = overrideMessage ??
133
- (errorCount > 0
134
- ? `${errorCount} error${errorCount !== 1 ? 's' : ''} in last ${windowSeconds / 60}min across ${this.files.length} file${this.files.length !== 1 ? 's' : ''}`
135
- : `No errors in last ${windowSeconds / 60}min`);
136
- const metrics = {
144
+ const fileCount = `${this.files.length} file${this.files.length !== 1 ? 's' : ''}`;
145
+ const message = burst ??
146
+ (errors > 0
147
+ ? `${errors} error${errors !== 1 ? 's' : ''} in last ${window} across ${fileCount}`
148
+ : `No errors in last ${window}`);
149
+ const lastErrorInWindow = this.lastError && now - this.lastError.at <= this.windowMs ? this.lastError.text : '';
150
+ return {
137
151
  status,
138
- mood,
152
+ mood: (0, thresholds_js_1.moodForStatus)(status),
139
153
  message,
140
- subStatus,
154
+ subStatus: burst ? 'error' : undefined,
141
155
  screenData: (0, buffer_js_1.buildLogScreen)(this.recentLines),
142
156
  metrics: {
143
- error_count: errorCount,
144
- warning_count: warningCount,
145
- window_seconds: windowSeconds,
157
+ error_count: errors,
158
+ warning_count: warnings,
159
+ window_seconds: this.windowMs / 1000,
146
160
  files_watched: this.files.length,
147
- last_error_message: lastError?.message ?? '',
161
+ last_error_message: lastErrorInWindow,
148
162
  },
149
163
  };
150
- try {
151
- await this.pushMetrics(metrics);
152
- }
153
- catch (err) {
154
- this.logger.error('Failed to push metrics', {
155
- error: err instanceof Error ? err.message : String(err),
156
- });
157
- }
158
164
  }
159
165
  }
160
166
  exports.LogWatcherNPC = LogWatcherNPC;
@@ -2,79 +2,126 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createTailer = createTailer;
4
4
  const node_fs_1 = require("node:fs");
5
- const node_readline_1 = require("node:readline");
5
+ const node_string_decoder_1 = require("node:string_decoder");
6
+ const DEFAULTS = {
7
+ pollMs: 1000,
8
+ maxLineChars: 16 * 1024,
9
+ maxReadBytes: 1024 * 1024,
10
+ maxBacklogBytes: 8 * 1024 * 1024,
11
+ };
12
+ function isNotFound(err) {
13
+ return err?.code === 'ENOENT';
14
+ }
6
15
  /**
7
- * Tail a file by watching for changes and reading new lines from the last
8
- * known byte offset. Handles file rotation (size shrinks -> reset to 0).
16
+ * Follow a growing file like `tail -F`: emit each complete new line, starting
17
+ * from the end of the file as it is now (or from the start of a file created
18
+ * later).
19
+ *
20
+ * Polls with `stat` instead of `fs.watch`: watchers follow the inode, so they
21
+ * go deaf after logrotate's rename-and-recreate, and they throw for files that
22
+ * don't exist yet. Rotation (new inode) and truncation (size shrinks) restart
23
+ * from offset 0. Reads are bounded per poll, a partial last line is held until
24
+ * its newline arrives, over-long lines are cut, and a backlog beyond
25
+ * `maxBacklogBytes` (a runaway logger) is skipped so the watcher stays current.
9
26
  */
10
- function createTailer(filePath, onLine) {
27
+ function createTailer(filePath, onLine, options = {}) {
28
+ const opts = { ...DEFAULTS, ...options };
11
29
  let offset = 0;
12
- let reading = false;
13
- let watcher = null;
30
+ let inode = null;
31
+ let partial = '';
32
+ let decoder = new node_string_decoder_1.StringDecoder('utf8');
33
+ let busy = false;
14
34
  let closed = false;
15
- async function init() {
16
- // Start at the end of the file so we only read new lines
35
+ /** False until the first successful stat (or ENOENT) fixes the starting offset. */
36
+ let started = false;
37
+ let lastError = '';
38
+ const reset = () => {
39
+ offset = 0;
40
+ partial = '';
41
+ decoder = new node_string_decoder_1.StringDecoder('utf8');
42
+ };
43
+ const reportError = (err) => {
44
+ const error = err instanceof Error ? err : new Error(String(err));
45
+ if (error.message === lastError)
46
+ return;
47
+ lastError = error.message;
48
+ opts.onError?.(error);
49
+ };
50
+ const emitText = (text) => {
51
+ const pieces = (partial + text).split('\n');
52
+ partial = pieces.pop().slice(0, opts.maxLineChars);
53
+ for (const piece of pieces) {
54
+ if (closed)
55
+ return;
56
+ onLine(piece.replace(/\r$/, '').slice(0, opts.maxLineChars));
57
+ }
58
+ };
59
+ async function readNew(size) {
60
+ if (size - offset > opts.maxBacklogBytes) {
61
+ // Too far behind: jump to the newest data rather than lag forever.
62
+ offset = size - opts.maxReadBytes;
63
+ partial = '';
64
+ decoder = new node_string_decoder_1.StringDecoder('utf8');
65
+ }
66
+ const length = Math.min(size - offset, opts.maxReadBytes);
67
+ const handle = await node_fs_1.promises.open(filePath, 'r');
17
68
  try {
18
- const stats = await node_fs_1.promises.stat(filePath);
19
- offset = stats.size;
69
+ const buf = Buffer.alloc(length);
70
+ const { bytesRead } = await handle.read(buf, 0, length, offset);
71
+ offset += bytesRead;
72
+ emitText(decoder.write(buf.subarray(0, bytesRead)));
20
73
  }
21
- catch {
22
- // File may not exist yet; start at 0
23
- offset = 0;
74
+ finally {
75
+ await handle.close();
24
76
  }
25
- watcher = (0, node_fs_1.watch)(filePath, () => {
26
- if (!closed) {
27
- void readNewLines();
28
- }
29
- });
30
- // Ignore watcher errors (e.g. file deleted then recreated)
31
- watcher.on('error', () => { });
32
77
  }
33
- async function readNewLines() {
34
- if (reading || closed)
78
+ async function poll() {
79
+ if (busy || closed)
35
80
  return;
36
- reading = true;
81
+ busy = true;
37
82
  try {
38
- const stats = await node_fs_1.promises.stat(filePath);
39
- // File rotation detected: size smaller than our offset
40
- if (stats.size < offset) {
41
- offset = 0;
42
- }
43
- // Nothing new to read
44
- if (stats.size === offset) {
45
- reading = false;
83
+ const st = await node_fs_1.promises.stat(filePath);
84
+ if (!started) {
85
+ // Start at the current end of the file so only new lines are reported.
86
+ started = true;
87
+ inode = st.ino;
88
+ offset = st.size;
89
+ lastError = '';
46
90
  return;
47
91
  }
48
- const stream = (0, node_fs_1.createReadStream)(filePath, {
49
- start: offset,
50
- encoding: 'utf-8',
51
- });
52
- const rl = (0, node_readline_1.createInterface)({ input: stream, crlfDelay: Infinity });
53
- let bytesRead = 0;
54
- for await (const line of rl) {
55
- if (closed)
56
- break;
57
- // Account for line content + newline character
58
- bytesRead += Buffer.byteLength(line, 'utf-8') + 1;
59
- onLine(line);
60
- }
61
- offset += bytesRead;
92
+ if (inode !== null && st.ino !== inode)
93
+ reset(); // rotated: a new file took the name
94
+ inode = st.ino;
95
+ if (st.size < offset)
96
+ reset(); // truncated in place
97
+ if (st.size > offset)
98
+ await readNew(st.size);
99
+ lastError = '';
62
100
  }
63
- catch {
64
- // File may have been removed; ignore and wait for next change
101
+ catch (err) {
102
+ if (isNotFound(err)) {
103
+ started = true;
104
+ // Rotated away and not yet recreated (or never created): start the
105
+ // replacement from its first byte.
106
+ if (inode !== null)
107
+ reportError(new Error(`${filePath} disappeared; waiting for it`));
108
+ inode = null;
109
+ reset();
110
+ }
111
+ else {
112
+ reportError(err);
113
+ }
65
114
  }
66
115
  finally {
67
- reading = false;
116
+ busy = false;
68
117
  }
69
118
  }
70
- void init();
119
+ const timer = setInterval(() => void poll(), opts.pollMs);
120
+ void poll();
71
121
  return {
72
122
  close() {
73
123
  closed = true;
74
- if (watcher) {
75
- watcher.close();
76
- watcher = null;
77
- }
124
+ clearInterval(timer);
78
125
  },
79
126
  };
80
127
  }
@@ -4,6 +4,7 @@ exports.ProcessWatcherNPC = void 0;
4
4
  const polling_npc_js_1 = require("../../core/polling-npc.js");
5
5
  const thresholds_js_1 = require("../../core/thresholds.js");
6
6
  const screen_js_1 = require("../../core/screen.js");
7
+ const errors_js_1 = require("../../core/errors.js");
7
8
  const process_scanner_js_1 = require("./process-scanner.js");
8
9
  const defaults_js_1 = require("./defaults.js");
9
10
  /** Max per-process panels on the companion dashboard. */
@@ -15,14 +16,17 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
15
16
  cpuHistories = new Map();
16
17
  previousRunning = null;
17
18
  constructor(config) {
18
- if (process.platform === 'win32') {
19
- throw new Error('process-watcher is not supported on Windows (relies on the POSIX `ps` command). ' +
20
- 'Run it under WSL/Linux, or use system-monitor instead.');
21
- }
19
+ (0, errors_js_1.assertPosix)('process-watcher', 'It relies on the POSIX `ps` command; run it under WSL/Linux, or use system-monitor instead.');
22
20
  super(config);
23
- this.processes = config.processes;
21
+ this.processes = [...new Set(config.processes.map((p) => p.trim()).filter(Boolean))];
22
+ if (this.processes.length === 0) {
23
+ throw new errors_js_1.NpcConfigError('process-watcher needs at least one process name (--processes)');
24
+ }
24
25
  this.cpuWarn = config.cpuWarn ?? defaults_js_1.DEFAULTS.cpuWarn;
25
26
  this.memWarn = config.memWarn ?? defaults_js_1.DEFAULTS.memWarn;
27
+ if (!(this.cpuWarn > 0) || !(this.memWarn > 0)) {
28
+ throw new errors_js_1.NpcConfigError('--cpu-warn and --mem-warn must be positive numbers');
29
+ }
26
30
  }
27
31
  get name() {
28
32
  return 'process-watcher';
@@ -33,9 +37,6 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
33
37
  get archetype() {
34
38
  return 'service';
35
39
  }
36
- get heartbeatSeconds() {
37
- return 30;
38
- }
39
40
  get companion() {
40
41
  return {
41
42
  screenType: 'dashboard-wall',
@@ -45,22 +46,28 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
45
46
  };
46
47
  }
47
48
  async collect() {
48
- const infos = await (0, process_scanner_js_1.scanProcesses)(this.processes);
49
+ let infos;
50
+ try {
51
+ infos = await (0, process_scanner_js_1.scanProcesses)(this.processes);
52
+ }
53
+ catch (err) {
54
+ // Not knowing is different from "everything died": report unknown and
55
+ // leave the transition baseline alone so recovery doesn't say "is back".
56
+ return this.scanFailed(err);
57
+ }
49
58
  const statuses = [];
50
- const messageParts = [];
59
+ const problems = [];
51
60
  const metrics = {};
52
61
  const panels = [];
53
62
  let totalRunning = 0;
54
- let totalMissing = 0;
55
63
  for (const info of infos) {
56
64
  const safeKey = info.name.replace(/[^a-zA-Z0-9_]/g, '_');
57
65
  if (!info.running) {
58
66
  statuses.push('critical');
59
- messageParts.push(`${info.name}: NOT FOUND`);
67
+ problems.push(`${info.name} DOWN`);
60
68
  metrics[`${safeKey}_running`] = 0;
61
69
  metrics[`${safeKey}_cpu`] = 0;
62
70
  metrics[`${safeKey}_mem_mb`] = 0;
63
- totalMissing++;
64
71
  if (panels.length < MAX_PANELS) {
65
72
  panels.push({ label: info.name, value: 'DOWN', color: 'red' });
66
73
  }
@@ -68,73 +75,76 @@ class ProcessWatcherNPC extends polling_npc_js_1.PollingNPC {
68
75
  }
69
76
  totalRunning++;
70
77
  const cpu = info.cpuPercent ?? 0;
78
+ const memMb = info.memMb ?? 0;
71
79
  metrics[`${safeKey}_running`] = 1;
72
80
  metrics[`${safeKey}_cpu`] = cpu;
73
- metrics[`${safeKey}_mem_mb`] = info.memMb ?? 0;
81
+ metrics[`${safeKey}_mem_mb`] = memMb;
74
82
  const history = this.cpuHistories.get(info.name) ?? [];
75
83
  (0, screen_js_1.pushHistory)(history, cpu);
76
84
  this.cpuHistories.set(info.name, history);
77
- const cpuHigh = cpu >= this.cpuWarn;
78
- const memHigh = (info.memMb ?? 0) >= this.memWarn;
79
- if (cpuHigh || memHigh) {
80
- statuses.push('warning');
81
- const warnings = [];
82
- if (cpuHigh)
83
- warnings.push(`cpu=${cpu}%`);
84
- if (memHigh)
85
- warnings.push(`mem=${info.memMb}MB`);
86
- messageParts.push(`${info.name}: WARN (${warnings.join(', ')})`);
87
- }
88
- else {
89
- statuses.push('healthy');
90
- messageParts.push(`${info.name}: OK`);
85
+ // Single warning thresholds: no critical level for resource use.
86
+ const cpuStatus = (0, thresholds_js_1.evaluateThreshold)(cpu, { warning: this.cpuWarn, critical: Infinity });
87
+ const memStatus = (0, thresholds_js_1.evaluateThreshold)(memMb, { warning: this.memWarn, critical: Infinity });
88
+ const status = (0, thresholds_js_1.worstStatus)([cpuStatus, memStatus]);
89
+ statuses.push(status);
90
+ if (status !== 'healthy') {
91
+ const high = [];
92
+ if (cpuStatus !== 'healthy')
93
+ high.push(`cpu ${cpu}%`);
94
+ if (memStatus !== 'healthy')
95
+ high.push(`mem ${Math.round(memMb)}MB`);
96
+ problems.push(`${info.name} high ${high.join(', ')}`);
91
97
  }
92
98
  if (panels.length < MAX_PANELS) {
93
99
  panels.push({
94
100
  label: info.name,
95
101
  value: `${cpu}%`,
96
- color: cpuHigh || memHigh ? 'amber' : 'green',
102
+ color: (0, screen_js_1.statusColor)(status),
97
103
  sparkline: [...history],
98
104
  });
99
105
  }
100
106
  }
107
+ const totalMissing = infos.length - totalRunning;
101
108
  metrics['total_watched'] = this.processes.length;
102
109
  metrics['total_running'] = totalRunning;
103
110
  metrics['total_missing'] = totalMissing;
104
111
  // Transition speech: compare the running set to last poll's. On the first
105
112
  // poll we only seed the set (no bogus "is back" for already-running procs).
106
113
  const currentRunning = new Set(infos.filter((i) => i.running).map((i) => i.name));
107
- let transitions = [];
108
- if (this.previousRunning !== null) {
109
- transitions = (0, process_scanner_js_1.detectTransitions)(this.previousRunning, currentRunning);
110
- }
114
+ const transitions = this.previousRunning === null ? [] : (0, process_scanner_js_1.detectTransitions)(this.previousRunning, currentRunning);
111
115
  this.previousRunning = currentRunning;
112
116
  const status = (0, thresholds_js_1.worstStatus)(statuses);
113
- const mood = deriveMood(status);
114
- // A death message takes over the bubble this cycle so it reads as an alert.
115
- const message = transitions.length > 0 ? transitions.join(' · ') : messageParts.join(' | ');
116
- const screenData = { layout: 'multi-metric', values: panels };
117
+ const summary = problems.length === 0
118
+ ? `All ${infos.length} running`
119
+ : `${totalRunning}/${infos.length} running · ${problems.join(' · ')}`;
120
+ // A death/return takes over the bubble this cycle so it reads as an alert.
121
+ const message = transitions.length > 0 ? transitions.join(' · ') : summary;
117
122
  return {
118
123
  status,
119
- mood,
124
+ mood: (0, thresholds_js_1.moodForStatus)(status),
120
125
  message,
121
126
  subStatus: totalMissing > 0 ? 'process-down' : undefined,
122
- screenData,
127
+ screenData: { layout: 'multi-metric', values: panels },
123
128
  metrics,
124
129
  };
125
130
  }
126
- }
127
- exports.ProcessWatcherNPC = ProcessWatcherNPC;
128
- function deriveMood(status) {
129
- switch (status) {
130
- case 'healthy':
131
- return 'happy';
132
- case 'warning':
133
- return 'concerned';
134
- case 'critical':
135
- return 'stressed';
136
- default:
137
- return 'idle';
131
+ scanFailed(err) {
132
+ const reason = err instanceof Error ? err.message : String(err);
133
+ this.logger.warn('Cannot list processes', { error: reason });
134
+ return {
135
+ status: 'unknown',
136
+ mood: (0, thresholds_js_1.moodForStatus)('unknown'),
137
+ message: 'Cannot list processes (ps failed)',
138
+ subStatus: 'scan-failed',
139
+ screenData: {
140
+ layout: 'multi-metric',
141
+ values: this.processes
142
+ .slice(0, MAX_PANELS)
143
+ .map((name) => ({ label: name, value: '?', color: 'gray' })),
144
+ },
145
+ metrics: { total_watched: this.processes.length },
146
+ };
138
147
  }
139
148
  }
149
+ exports.ProcessWatcherNPC = ProcessWatcherNPC;
140
150
  //# sourceMappingURL=index.js.map