@nocville/npc 0.2.2 → 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 (96) hide show
  1. package/README.md +23 -5
  2. package/dist/cjs/cli.js +55 -34
  3. package/dist/cjs/core/base-npc.js +8 -0
  4. package/dist/cjs/core/config.js +19 -2
  5. package/dist/cjs/core/errors.js +23 -0
  6. package/dist/cjs/core/http.js +121 -0
  7. package/dist/cjs/core/pixel-art.js +110 -0
  8. package/dist/cjs/core/polling-npc.js +99 -14
  9. package/dist/cjs/core/text.js +13 -0
  10. package/dist/cjs/core/thresholds.js +29 -0
  11. package/dist/cjs/index.js +10 -2
  12. package/dist/cjs/npcs/agent-common/session.js +1 -0
  13. package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
  14. package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
  15. package/dist/cjs/npcs/github-watcher/index.js +82 -36
  16. package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
  17. package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
  18. package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
  19. package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
  20. package/dist/cjs/npcs/log-watcher/index.js +109 -103
  21. package/dist/cjs/npcs/log-watcher/tail.js +100 -53
  22. package/dist/cjs/npcs/process-watcher/index.js +61 -51
  23. package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
  24. package/dist/cjs/npcs/receptionist/defaults.js +4 -0
  25. package/dist/cjs/npcs/receptionist/index.js +45 -19
  26. package/dist/cjs/npcs/receptionist/weather.js +42 -47
  27. package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
  28. package/dist/cjs/npcs/system-monitor/index.js +52 -28
  29. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
  30. package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
  31. package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
  32. package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
  33. package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
  34. package/dist/cli.js +56 -35
  35. package/dist/core/base-npc.d.ts +5 -0
  36. package/dist/core/base-npc.js +8 -0
  37. package/dist/core/config.d.ts +5 -0
  38. package/dist/core/config.js +18 -2
  39. package/dist/core/errors.d.ts +11 -0
  40. package/dist/core/errors.js +18 -0
  41. package/dist/core/http.d.ts +52 -0
  42. package/dist/core/http.js +111 -0
  43. package/dist/core/pixel-art.d.ts +38 -0
  44. package/dist/core/pixel-art.js +105 -0
  45. package/dist/core/polling-npc.d.ts +22 -1
  46. package/dist/core/polling-npc.js +98 -13
  47. package/dist/core/text.d.ts +6 -0
  48. package/dist/core/text.js +12 -0
  49. package/dist/core/thresholds.d.ts +9 -1
  50. package/dist/core/thresholds.js +27 -0
  51. package/dist/index.d.ts +5 -2
  52. package/dist/index.js +4 -2
  53. package/dist/npcs/agent-common/session.js +1 -0
  54. package/dist/npcs/github-watcher/defaults.d.ts +6 -0
  55. package/dist/npcs/github-watcher/defaults.js +7 -1
  56. package/dist/npcs/github-watcher/github-api.d.ts +21 -7
  57. package/dist/npcs/github-watcher/github-api.js +80 -45
  58. package/dist/npcs/github-watcher/index.d.ts +6 -0
  59. package/dist/npcs/github-watcher/index.js +83 -37
  60. package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
  61. package/dist/npcs/internet-connectivity/checks.js +70 -67
  62. package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
  63. package/dist/npcs/internet-connectivity/defaults.js +5 -2
  64. package/dist/npcs/internet-connectivity/index.d.ts +19 -6
  65. package/dist/npcs/internet-connectivity/index.js +135 -133
  66. package/dist/npcs/log-watcher/buffer.d.ts +22 -0
  67. package/dist/npcs/log-watcher/buffer.js +58 -0
  68. package/dist/npcs/log-watcher/index.d.ts +22 -21
  69. package/dist/npcs/log-watcher/index.js +112 -106
  70. package/dist/npcs/log-watcher/tail.d.ts +23 -3
  71. package/dist/npcs/log-watcher/tail.js +101 -54
  72. package/dist/npcs/process-watcher/index.d.ts +1 -1
  73. package/dist/npcs/process-watcher/index.js +62 -52
  74. package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
  75. package/dist/npcs/process-watcher/process-scanner.js +61 -71
  76. package/dist/npcs/receptionist/defaults.d.ts +4 -0
  77. package/dist/npcs/receptionist/defaults.js +4 -0
  78. package/dist/npcs/receptionist/index.d.ts +12 -2
  79. package/dist/npcs/receptionist/index.js +46 -20
  80. package/dist/npcs/receptionist/weather.d.ts +8 -6
  81. package/dist/npcs/receptionist/weather.js +39 -45
  82. package/dist/npcs/system-monitor/collectors.d.ts +15 -3
  83. package/dist/npcs/system-monitor/collectors.js +64 -17
  84. package/dist/npcs/system-monitor/defaults.d.ts +1 -1
  85. package/dist/npcs/system-monitor/index.d.ts +2 -1
  86. package/dist/npcs/system-monitor/index.js +54 -30
  87. package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
  88. package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
  89. package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
  90. package/dist/npcs/web-page-watcher/capture.js +21 -1
  91. package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
  92. package/dist/npcs/web-page-watcher/defaults.js +41 -1
  93. package/dist/npcs/web-page-watcher/index.d.ts +18 -1
  94. package/dist/npcs/web-page-watcher/index.js +121 -70
  95. package/dist/npcs/web-page-watcher/pixelate.js +2 -1
  96. package/package.json +2 -2
@@ -1,46 +1,84 @@
1
- import { basename } from 'node:path';
2
- import { BaseNPC } from '../../core/base-npc.js';
3
- import { evaluateThreshold, worstStatus } from '../../core/thresholds.js';
4
- import { truncate } from '../../core/text.js';
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, resolve } from 'node:path';
3
+ import { PollingNPC } from '../../core/polling-npc.js';
4
+ import { evaluateThreshold, worstStatus, moodForStatus, thresholdProblem, } from '../../core/thresholds.js';
5
+ import { NpcConfigError } from '../../core/errors.js';
5
6
  import { createTailer } from './tail.js';
6
- import { classifyLine, pushRecent, buildLogScreen, shouldEmitBurst } from './buffer.js';
7
+ import { classifyLine, cleanLogLine, pushRecent, buildLogScreen, shouldEmitBurst, WindowCounter, } from './buffer.js';
7
8
  import { DEFAULTS } from './defaults.js';
8
- export class LogWatcherNPC extends BaseNPC {
9
+ /** Lines shown on the log terminal, and their width. */
10
+ const SCREEN_LINES = 12;
11
+ const SCREEN_LINE_CHARS = 80;
12
+ function compilePattern(flag, source) {
13
+ try {
14
+ return new RegExp(source);
15
+ }
16
+ catch (err) {
17
+ throw new NpcConfigError(`${flag}: invalid regular expression (${err instanceof Error ? err.message : String(err)})`);
18
+ }
19
+ }
20
+ /** "45s", "5min", "1.5h" */
21
+ function formatWindow(seconds) {
22
+ if (seconds < 60)
23
+ return `${seconds}s`;
24
+ if (seconds < 3600)
25
+ return `${Math.round((seconds / 60) * 10) / 10}min`;
26
+ return `${Math.round((seconds / 3600) * 10) / 10}h`;
27
+ }
28
+ /**
29
+ * Tails log files and reports error bursts. A periodic report (every
30
+ * `reportInterval`) carries the error count over the sliding window and the
31
+ * last lines for the log terminal; a fresh error line also triggers an
32
+ * immediate report, debounced to one per 5s, so a burst shows up at once.
33
+ * Every report is one composed push, never a push per line.
34
+ */
35
+ export class LogWatcherNPC extends PollingNPC {
9
36
  tailers = [];
10
- timer = null;
11
- entries = [];
37
+ counter;
12
38
  recentLines = [];
13
- lastBurstPush = 0;
39
+ lastError = null;
40
+ /** The error line that triggered an immediate report, consumed by the next collect(). */
41
+ burstLine = null;
42
+ lastBurstAt = 0;
14
43
  errorRegex;
15
44
  warningRegex;
16
45
  windowMs;
17
- errorWarn;
18
- errorCrit;
19
- reportInterval;
46
+ errorThreshold;
20
47
  files;
21
48
  constructor(config) {
22
- super(config);
23
- this.files = config.files;
24
- this.errorRegex = new RegExp(config.errorPattern ?? DEFAULTS.errorPattern);
25
- this.warningRegex = new RegExp(config.warningPattern ?? DEFAULTS.warningPattern);
26
- this.windowMs = (config.windowSeconds ?? DEFAULTS.windowSeconds) * 1000;
27
- this.errorWarn = config.errorWarn ?? DEFAULTS.errorWarn;
28
- this.errorCrit = config.errorCrit ?? DEFAULTS.errorCrit;
29
- this.reportInterval = config.reportInterval ?? DEFAULTS.reportInterval;
49
+ super({
50
+ ...config,
51
+ pollInterval: config.reportInterval ?? config.pollInterval ?? DEFAULTS.reportInterval,
52
+ });
53
+ this.files = [...new Set(config.files.map((f) => f.trim()).filter(Boolean))].map((f) => resolve(f));
54
+ if (this.files.length === 0) {
55
+ throw new NpcConfigError('log-watcher needs at least one file (--files)');
56
+ }
57
+ this.errorRegex = compilePattern('--error-pattern', config.errorPattern ?? DEFAULTS.errorPattern);
58
+ this.warningRegex = compilePattern('--warning-pattern', config.warningPattern ?? DEFAULTS.warningPattern);
59
+ const windowSeconds = config.windowSeconds ?? DEFAULTS.windowSeconds;
60
+ if (!(windowSeconds > 0))
61
+ throw new NpcConfigError('--window must be a positive number of seconds');
62
+ this.windowMs = windowSeconds * 1000;
63
+ this.counter = new WindowCounter(this.windowMs);
64
+ this.errorThreshold = {
65
+ warning: config.errorWarn ?? DEFAULTS.errorWarn,
66
+ critical: config.errorCrit ?? DEFAULTS.errorCrit,
67
+ direction: 'above',
68
+ };
69
+ const problem = thresholdProblem('--error-warn/--error-crit', this.errorThreshold);
70
+ if (problem)
71
+ throw new NpcConfigError(problem);
30
72
  }
31
73
  get name() {
32
74
  return 'log-watcher';
33
75
  }
34
76
  get defaultDisplayName() {
35
- const first = this.files[0];
36
- return first ? `Logs: ${basename(first)}` : 'Logs';
77
+ return `Logs: ${basename(this.files[0])}`;
37
78
  }
38
79
  get archetype() {
39
80
  return 'service';
40
81
  }
41
- get heartbeatSeconds() {
42
- return 30;
43
- }
44
82
  get behavior() {
45
83
  return { idle: 'wander' };
46
84
  }
@@ -49,109 +87,77 @@ export class LogWatcherNPC extends BaseNPC {
49
87
  screenType: 'log-terminal',
50
88
  size: { w: 2, h: 2 },
51
89
  placement: 'adjacent-wall',
52
- label: this.files[0] ? basename(this.files[0]) : 'log',
90
+ label: basename(this.files[0]),
53
91
  };
54
92
  }
55
93
  async onStart() {
56
94
  for (const filePath of this.files) {
57
- this.logger.info(`Tailing ${filePath}`);
58
- const tailer = createTailer(filePath, (line) => {
59
- this.handleLine(line);
60
- });
61
- this.tailers.push(tailer);
95
+ if (existsSync(filePath)) {
96
+ this.logger.info(`Tailing ${filePath}`);
97
+ }
98
+ else {
99
+ this.logger.warn(`${filePath} does not exist yet; will tail it once it appears`);
100
+ }
101
+ this.tailers.push(createTailer(filePath, (line) => this.handleLine(line), {
102
+ onError: (err) => this.logger.warn(`Cannot read ${filePath}`, { error: err.message }),
103
+ }));
62
104
  }
63
- // Report immediately, then on interval
64
- await this.report();
65
- this.timer = setInterval(() => {
66
- void this.report();
67
- }, this.reportInterval);
105
+ await super.onStart();
68
106
  }
69
107
  async onStop() {
70
- if (this.timer) {
71
- clearInterval(this.timer);
72
- this.timer = null;
73
- }
74
- for (const tailer of this.tailers) {
108
+ for (const tailer of this.tailers)
75
109
  tailer.close();
76
- }
77
110
  this.tailers = [];
111
+ await super.onStop();
78
112
  }
79
- handleLine(line) {
80
- const now = Date.now();
113
+ /** Visible for tests: feed one raw line as if a tailer had read it. */
114
+ handleLine(line, now = Date.now()) {
81
115
  const level = classifyLine(line, this.errorRegex, this.warningRegex);
82
- // Ring buffer holds ALL lines (matched ones flagged) for the log TV.
83
- pushRecent(this.recentLines, line, level);
84
- if (level === 'error') {
85
- this.entries.push({ timestamp: now, type: 'error', message: line });
86
- this.onErrorBurst(line);
87
- }
88
- else if (level === 'warning') {
89
- this.entries.push({ timestamp: now, type: 'warning', message: line });
90
- }
91
- }
92
- /** A fresh error triggers an immediate push, debounced to at most once / 5s. */
93
- onErrorBurst(line) {
94
- const now = Date.now();
95
- if (!shouldEmitBurst(this.lastBurstPush, now))
116
+ // The ring buffer holds ALL lines (matched ones flagged) for the log TV.
117
+ pushRecent(this.recentLines, cleanLogLine(line, SCREEN_LINE_CHARS), level, SCREEN_LINES);
118
+ if (level === 'info')
96
119
  return;
97
- this.lastBurstPush = now;
98
- void this.pushReport(truncate(line, 200), 'error');
99
- }
100
- pruneWindow() {
101
- const cutoff = Date.now() - this.windowMs;
102
- this.entries = this.entries.filter((e) => e.timestamp >= cutoff);
103
- }
104
- async report() {
105
- if (this.state !== 'running')
120
+ this.counter.add(level, now);
121
+ if (level !== 'error')
106
122
  return;
107
- await this.pushReport();
123
+ const text = cleanLogLine(line);
124
+ this.lastError = { text, at: now };
125
+ if (shouldEmitBurst(this.lastBurstAt, now)) {
126
+ this.lastBurstAt = now;
127
+ this.burstLine = text;
128
+ this.collectNow();
129
+ }
108
130
  }
109
- /**
110
- * Build and push the current status. If `overrideMessage` is set (burst push),
111
- * it drives the bubble and the status floors at warning.
112
- */
113
- async pushReport(overrideMessage, subStatus) {
114
- if (this.state !== 'running')
115
- return;
116
- this.pruneWindow();
117
- const errorCount = this.entries.filter((e) => e.type === 'error').length;
118
- const warningCount = this.entries.filter((e) => e.type === 'warning').length;
119
- const windowSeconds = this.windowMs / 1000;
120
- let status = evaluateThreshold(errorCount, {
121
- warning: this.errorWarn,
122
- critical: this.errorCrit,
123
- direction: 'above',
124
- });
125
- if (overrideMessage)
131
+ async collect() {
132
+ const now = Date.now();
133
+ const { errors, warnings } = this.counter.counts(now);
134
+ const window = formatWindow(this.windowMs / 1000);
135
+ const burst = this.burstLine;
136
+ this.burstLine = null;
137
+ let status = evaluateThreshold(errors, this.errorThreshold);
138
+ // A burst report drives the bubble with the error itself and floors at warning.
139
+ if (burst)
126
140
  status = worstStatus([status, 'warning']);
127
- const mood = status === 'critical' ? 'stressed' : status === 'warning' ? 'concerned' : 'happy';
128
- const lastError = this.entries.filter((e) => e.type === 'error').at(-1);
129
- const message = overrideMessage ??
130
- (errorCount > 0
131
- ? `${errorCount} error${errorCount !== 1 ? 's' : ''} in last ${windowSeconds / 60}min across ${this.files.length} file${this.files.length !== 1 ? 's' : ''}`
132
- : `No errors in last ${windowSeconds / 60}min`);
133
- const metrics = {
141
+ const fileCount = `${this.files.length} file${this.files.length !== 1 ? 's' : ''}`;
142
+ const message = burst ??
143
+ (errors > 0
144
+ ? `${errors} error${errors !== 1 ? 's' : ''} in last ${window} across ${fileCount}`
145
+ : `No errors in last ${window}`);
146
+ const lastErrorInWindow = this.lastError && now - this.lastError.at <= this.windowMs ? this.lastError.text : '';
147
+ return {
134
148
  status,
135
- mood,
149
+ mood: moodForStatus(status),
136
150
  message,
137
- subStatus,
151
+ subStatus: burst ? 'error' : undefined,
138
152
  screenData: buildLogScreen(this.recentLines),
139
153
  metrics: {
140
- error_count: errorCount,
141
- warning_count: warningCount,
142
- window_seconds: windowSeconds,
154
+ error_count: errors,
155
+ warning_count: warnings,
156
+ window_seconds: this.windowMs / 1000,
143
157
  files_watched: this.files.length,
144
- last_error_message: lastError?.message ?? '',
158
+ last_error_message: lastErrorInWindow,
145
159
  },
146
160
  };
147
- try {
148
- await this.pushMetrics(metrics);
149
- }
150
- catch (err) {
151
- this.logger.error('Failed to push metrics', {
152
- error: err instanceof Error ? err.message : String(err),
153
- });
154
- }
155
161
  }
156
162
  }
157
163
  //# sourceMappingURL=index.js.map
@@ -1,9 +1,29 @@
1
1
  export interface FileTailer {
2
2
  close(): void;
3
3
  }
4
+ export interface TailerOptions {
5
+ /** How often to stat the file for growth/rotation (ms). */
6
+ pollMs?: number;
7
+ /** Longest line kept, in characters; the rest of a longer line is dropped. */
8
+ maxLineChars?: number;
9
+ /** Max bytes read per poll. */
10
+ maxReadBytes?: number;
11
+ /** When more than this many bytes are pending, skip ahead to the newest data. */
12
+ maxBacklogBytes?: number;
13
+ /** Called (once per distinct error) when the file can't be read. */
14
+ onError?: (err: Error) => void;
15
+ }
4
16
  /**
5
- * Tail a file by watching for changes and reading new lines from the last
6
- * known byte offset. Handles file rotation (size shrinks -> reset to 0).
17
+ * Follow a growing file like `tail -F`: emit each complete new line, starting
18
+ * from the end of the file as it is now (or from the start of a file created
19
+ * later).
20
+ *
21
+ * Polls with `stat` instead of `fs.watch`: watchers follow the inode, so they
22
+ * go deaf after logrotate's rename-and-recreate, and they throw for files that
23
+ * don't exist yet. Rotation (new inode) and truncation (size shrinks) restart
24
+ * from offset 0. Reads are bounded per poll, a partial last line is held until
25
+ * its newline arrives, over-long lines are cut, and a backlog beyond
26
+ * `maxBacklogBytes` (a runaway logger) is skipped so the watcher stays current.
7
27
  */
8
- export declare function createTailer(filePath: string, onLine: (line: string) => void): FileTailer;
28
+ export declare function createTailer(filePath: string, onLine: (line: string) => void, options?: TailerOptions): FileTailer;
9
29
  //# sourceMappingURL=tail.d.ts.map
@@ -1,77 +1,124 @@
1
- import { watch, createReadStream, promises as fsp } from 'node:fs';
2
- import { createInterface } from 'node:readline';
1
+ import { promises as fsp } from 'node:fs';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ const DEFAULTS = {
4
+ pollMs: 1000,
5
+ maxLineChars: 16 * 1024,
6
+ maxReadBytes: 1024 * 1024,
7
+ maxBacklogBytes: 8 * 1024 * 1024,
8
+ };
9
+ function isNotFound(err) {
10
+ return err?.code === 'ENOENT';
11
+ }
3
12
  /**
4
- * Tail a file by watching for changes and reading new lines from the last
5
- * known byte offset. Handles file rotation (size shrinks -> reset to 0).
13
+ * Follow a growing file like `tail -F`: emit each complete new line, starting
14
+ * from the end of the file as it is now (or from the start of a file created
15
+ * later).
16
+ *
17
+ * Polls with `stat` instead of `fs.watch`: watchers follow the inode, so they
18
+ * go deaf after logrotate's rename-and-recreate, and they throw for files that
19
+ * don't exist yet. Rotation (new inode) and truncation (size shrinks) restart
20
+ * from offset 0. Reads are bounded per poll, a partial last line is held until
21
+ * its newline arrives, over-long lines are cut, and a backlog beyond
22
+ * `maxBacklogBytes` (a runaway logger) is skipped so the watcher stays current.
6
23
  */
7
- export function createTailer(filePath, onLine) {
24
+ export function createTailer(filePath, onLine, options = {}) {
25
+ const opts = { ...DEFAULTS, ...options };
8
26
  let offset = 0;
9
- let reading = false;
10
- let watcher = null;
27
+ let inode = null;
28
+ let partial = '';
29
+ let decoder = new StringDecoder('utf8');
30
+ let busy = false;
11
31
  let closed = false;
12
- async function init() {
13
- // Start at the end of the file so we only read new lines
32
+ /** False until the first successful stat (or ENOENT) fixes the starting offset. */
33
+ let started = false;
34
+ let lastError = '';
35
+ const reset = () => {
36
+ offset = 0;
37
+ partial = '';
38
+ decoder = new StringDecoder('utf8');
39
+ };
40
+ const reportError = (err) => {
41
+ const error = err instanceof Error ? err : new Error(String(err));
42
+ if (error.message === lastError)
43
+ return;
44
+ lastError = error.message;
45
+ opts.onError?.(error);
46
+ };
47
+ const emitText = (text) => {
48
+ const pieces = (partial + text).split('\n');
49
+ partial = pieces.pop().slice(0, opts.maxLineChars);
50
+ for (const piece of pieces) {
51
+ if (closed)
52
+ return;
53
+ onLine(piece.replace(/\r$/, '').slice(0, opts.maxLineChars));
54
+ }
55
+ };
56
+ async function readNew(size) {
57
+ if (size - offset > opts.maxBacklogBytes) {
58
+ // Too far behind: jump to the newest data rather than lag forever.
59
+ offset = size - opts.maxReadBytes;
60
+ partial = '';
61
+ decoder = new StringDecoder('utf8');
62
+ }
63
+ const length = Math.min(size - offset, opts.maxReadBytes);
64
+ const handle = await fsp.open(filePath, 'r');
14
65
  try {
15
- const stats = await fsp.stat(filePath);
16
- offset = stats.size;
66
+ const buf = Buffer.alloc(length);
67
+ const { bytesRead } = await handle.read(buf, 0, length, offset);
68
+ offset += bytesRead;
69
+ emitText(decoder.write(buf.subarray(0, bytesRead)));
17
70
  }
18
- catch {
19
- // File may not exist yet; start at 0
20
- offset = 0;
71
+ finally {
72
+ await handle.close();
21
73
  }
22
- watcher = watch(filePath, () => {
23
- if (!closed) {
24
- void readNewLines();
25
- }
26
- });
27
- // Ignore watcher errors (e.g. file deleted then recreated)
28
- watcher.on('error', () => { });
29
74
  }
30
- async function readNewLines() {
31
- if (reading || closed)
75
+ async function poll() {
76
+ if (busy || closed)
32
77
  return;
33
- reading = true;
78
+ busy = true;
34
79
  try {
35
- const stats = await fsp.stat(filePath);
36
- // File rotation detected: size smaller than our offset
37
- if (stats.size < offset) {
38
- offset = 0;
39
- }
40
- // Nothing new to read
41
- if (stats.size === offset) {
42
- reading = false;
80
+ const st = await fsp.stat(filePath);
81
+ if (!started) {
82
+ // Start at the current end of the file so only new lines are reported.
83
+ started = true;
84
+ inode = st.ino;
85
+ offset = st.size;
86
+ lastError = '';
43
87
  return;
44
88
  }
45
- const stream = createReadStream(filePath, {
46
- start: offset,
47
- encoding: 'utf-8',
48
- });
49
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
50
- let bytesRead = 0;
51
- for await (const line of rl) {
52
- if (closed)
53
- break;
54
- // Account for line content + newline character
55
- bytesRead += Buffer.byteLength(line, 'utf-8') + 1;
56
- onLine(line);
57
- }
58
- offset += bytesRead;
89
+ if (inode !== null && st.ino !== inode)
90
+ reset(); // rotated: a new file took the name
91
+ inode = st.ino;
92
+ if (st.size < offset)
93
+ reset(); // truncated in place
94
+ if (st.size > offset)
95
+ await readNew(st.size);
96
+ lastError = '';
59
97
  }
60
- catch {
61
- // File may have been removed; ignore and wait for next change
98
+ catch (err) {
99
+ if (isNotFound(err)) {
100
+ started = true;
101
+ // Rotated away and not yet recreated (or never created): start the
102
+ // replacement from its first byte.
103
+ if (inode !== null)
104
+ reportError(new Error(`${filePath} disappeared; waiting for it`));
105
+ inode = null;
106
+ reset();
107
+ }
108
+ else {
109
+ reportError(err);
110
+ }
62
111
  }
63
112
  finally {
64
- reading = false;
113
+ busy = false;
65
114
  }
66
115
  }
67
- void init();
116
+ const timer = setInterval(() => void poll(), opts.pollMs);
117
+ void poll();
68
118
  return {
69
119
  close() {
70
120
  closed = true;
71
- if (watcher) {
72
- watcher.close();
73
- watcher = null;
74
- }
121
+ clearInterval(timer);
75
122
  },
76
123
  };
77
124
  }
@@ -19,8 +19,8 @@ export declare class ProcessWatcherNPC extends PollingNPC {
19
19
  get name(): string;
20
20
  protected get defaultDisplayName(): string;
21
21
  get archetype(): Archetype;
22
- get heartbeatSeconds(): number;
23
22
  get companion(): CompanionDecl;
24
23
  protected collect(): Promise<CollectedMetrics>;
24
+ private scanFailed;
25
25
  }
26
26
  //# sourceMappingURL=index.d.ts.map