@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.
- package/README.md +23 -5
- package/dist/cjs/cli.js +55 -34
- package/dist/cjs/core/base-npc.js +8 -0
- package/dist/cjs/core/config.js +19 -2
- package/dist/cjs/core/errors.js +23 -0
- package/dist/cjs/core/http.js +121 -0
- package/dist/cjs/core/pixel-art.js +110 -0
- package/dist/cjs/core/polling-npc.js +99 -14
- package/dist/cjs/core/text.js +13 -0
- package/dist/cjs/core/thresholds.js +29 -0
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/npcs/agent-common/session.js +1 -0
- package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
- package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
- package/dist/cjs/npcs/github-watcher/index.js +82 -36
- package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
- package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
- package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
- package/dist/cjs/npcs/log-watcher/index.js +109 -103
- package/dist/cjs/npcs/log-watcher/tail.js +100 -53
- package/dist/cjs/npcs/process-watcher/index.js +61 -51
- package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
- package/dist/cjs/npcs/receptionist/defaults.js +4 -0
- package/dist/cjs/npcs/receptionist/index.js +45 -19
- package/dist/cjs/npcs/receptionist/weather.js +42 -47
- package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
- package/dist/cjs/npcs/system-monitor/index.js +52 -28
- package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
- package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
- package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
- package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
- package/dist/cli.js +56 -35
- package/dist/core/base-npc.d.ts +5 -0
- package/dist/core/base-npc.js +8 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +18 -2
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +18 -0
- package/dist/core/http.d.ts +52 -0
- package/dist/core/http.js +111 -0
- package/dist/core/pixel-art.d.ts +38 -0
- package/dist/core/pixel-art.js +105 -0
- package/dist/core/polling-npc.d.ts +22 -1
- package/dist/core/polling-npc.js +98 -13
- package/dist/core/text.d.ts +6 -0
- package/dist/core/text.js +12 -0
- package/dist/core/thresholds.d.ts +9 -1
- package/dist/core/thresholds.js +27 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -2
- package/dist/npcs/agent-common/session.js +1 -0
- package/dist/npcs/github-watcher/defaults.d.ts +6 -0
- package/dist/npcs/github-watcher/defaults.js +7 -1
- package/dist/npcs/github-watcher/github-api.d.ts +21 -7
- package/dist/npcs/github-watcher/github-api.js +80 -45
- package/dist/npcs/github-watcher/index.d.ts +6 -0
- package/dist/npcs/github-watcher/index.js +83 -37
- package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
- package/dist/npcs/internet-connectivity/checks.js +70 -67
- package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
- package/dist/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/npcs/internet-connectivity/index.d.ts +19 -6
- package/dist/npcs/internet-connectivity/index.js +135 -133
- package/dist/npcs/log-watcher/buffer.d.ts +22 -0
- package/dist/npcs/log-watcher/buffer.js +58 -0
- package/dist/npcs/log-watcher/index.d.ts +22 -21
- package/dist/npcs/log-watcher/index.js +112 -106
- package/dist/npcs/log-watcher/tail.d.ts +23 -3
- package/dist/npcs/log-watcher/tail.js +101 -54
- package/dist/npcs/process-watcher/index.d.ts +1 -1
- package/dist/npcs/process-watcher/index.js +62 -52
- package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
- package/dist/npcs/process-watcher/process-scanner.js +61 -71
- package/dist/npcs/receptionist/defaults.d.ts +4 -0
- package/dist/npcs/receptionist/defaults.js +4 -0
- package/dist/npcs/receptionist/index.d.ts +12 -2
- package/dist/npcs/receptionist/index.js +46 -20
- package/dist/npcs/receptionist/weather.d.ts +8 -6
- package/dist/npcs/receptionist/weather.js +39 -45
- package/dist/npcs/system-monitor/collectors.d.ts +15 -3
- package/dist/npcs/system-monitor/collectors.js +64 -17
- package/dist/npcs/system-monitor/defaults.d.ts +1 -1
- package/dist/npcs/system-monitor/index.d.ts +2 -1
- package/dist/npcs/system-monitor/index.js +54 -30
- package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
- package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
- package/dist/npcs/web-page-watcher/capture.js +21 -1
- package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
- package/dist/npcs/web-page-watcher/defaults.js +41 -1
- package/dist/npcs/web-page-watcher/index.d.ts +18 -1
- package/dist/npcs/web-page-watcher/index.js +121 -70
- package/dist/npcs/web-page-watcher/pixelate.js +2 -1
- package/package.json +2 -2
|
@@ -1,46 +1,84 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
entries = [];
|
|
37
|
+
counter;
|
|
12
38
|
recentLines = [];
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
errorCrit;
|
|
19
|
-
reportInterval;
|
|
46
|
+
errorThreshold;
|
|
20
47
|
files;
|
|
21
48
|
constructor(config) {
|
|
22
|
-
super(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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:
|
|
90
|
+
label: basename(this.files[0]),
|
|
53
91
|
};
|
|
54
92
|
}
|
|
55
93
|
async onStart() {
|
|
56
94
|
for (const filePath of this.files) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
//
|
|
83
|
-
pushRecent(this.recentLines, line, level);
|
|
84
|
-
if (level === '
|
|
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.
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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:
|
|
141
|
-
warning_count:
|
|
142
|
-
window_seconds:
|
|
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:
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
10
|
-
let
|
|
27
|
+
let inode = null;
|
|
28
|
+
let partial = '';
|
|
29
|
+
let decoder = new StringDecoder('utf8');
|
|
30
|
+
let busy = false;
|
|
11
31
|
let closed = false;
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
31
|
-
if (
|
|
75
|
+
async function poll() {
|
|
76
|
+
if (busy || closed)
|
|
32
77
|
return;
|
|
33
|
-
|
|
78
|
+
busy = true;
|
|
34
79
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
+
busy = false;
|
|
65
114
|
}
|
|
66
115
|
}
|
|
67
|
-
void
|
|
116
|
+
const timer = setInterval(() => void poll(), opts.pollMs);
|
|
117
|
+
void poll();
|
|
68
118
|
return {
|
|
69
119
|
close() {
|
|
70
120
|
closed = true;
|
|
71
|
-
|
|
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
|