watchfix 0.1.0

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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/agents/base.d.ts +19 -0
  4. package/dist/agents/base.js +140 -0
  5. package/dist/agents/claude.d.ts +11 -0
  6. package/dist/agents/claude.js +6 -0
  7. package/dist/agents/codex.d.ts +11 -0
  8. package/dist/agents/codex.js +6 -0
  9. package/dist/agents/defaults.d.ts +21 -0
  10. package/dist/agents/defaults.js +21 -0
  11. package/dist/agents/gemini.d.ts +11 -0
  12. package/dist/agents/gemini.js +6 -0
  13. package/dist/agents/index.d.ts +19 -0
  14. package/dist/agents/index.js +63 -0
  15. package/dist/agents/types.d.ts +22 -0
  16. package/dist/agents/types.js +1 -0
  17. package/dist/cli/commands/clean.d.ts +9 -0
  18. package/dist/cli/commands/clean.js +173 -0
  19. package/dist/cli/commands/config.d.ts +7 -0
  20. package/dist/cli/commands/config.js +134 -0
  21. package/dist/cli/commands/fix.d.ts +12 -0
  22. package/dist/cli/commands/fix.js +391 -0
  23. package/dist/cli/commands/ignore.d.ts +7 -0
  24. package/dist/cli/commands/ignore.js +74 -0
  25. package/dist/cli/commands/init.d.ts +5 -0
  26. package/dist/cli/commands/init.js +115 -0
  27. package/dist/cli/commands/logs.d.ts +9 -0
  28. package/dist/cli/commands/logs.js +106 -0
  29. package/dist/cli/commands/show.d.ts +8 -0
  30. package/dist/cli/commands/show.js +165 -0
  31. package/dist/cli/commands/status.d.ts +7 -0
  32. package/dist/cli/commands/status.js +110 -0
  33. package/dist/cli/commands/stop.d.ts +7 -0
  34. package/dist/cli/commands/stop.js +106 -0
  35. package/dist/cli/commands/version.d.ts +7 -0
  36. package/dist/cli/commands/version.js +36 -0
  37. package/dist/cli/commands/watch.d.ts +10 -0
  38. package/dist/cli/commands/watch.js +204 -0
  39. package/dist/cli/index.d.ts +2 -0
  40. package/dist/cli/index.js +152 -0
  41. package/dist/config/loader.d.ts +4 -0
  42. package/dist/config/loader.js +96 -0
  43. package/dist/config/schema.d.ts +375 -0
  44. package/dist/config/schema.js +99 -0
  45. package/dist/db/index.d.ts +15 -0
  46. package/dist/db/index.js +71 -0
  47. package/dist/db/queries.d.ts +45 -0
  48. package/dist/db/queries.js +111 -0
  49. package/dist/db/schema.d.ts +4 -0
  50. package/dist/db/schema.js +84 -0
  51. package/dist/fixer/context.d.ts +9 -0
  52. package/dist/fixer/context.js +361 -0
  53. package/dist/fixer/index.d.ts +37 -0
  54. package/dist/fixer/index.js +398 -0
  55. package/dist/fixer/lock.d.ts +7 -0
  56. package/dist/fixer/lock.js +49 -0
  57. package/dist/fixer/output.d.ts +21 -0
  58. package/dist/fixer/output.js +108 -0
  59. package/dist/fixer/queue.d.ts +15 -0
  60. package/dist/fixer/queue.js +53 -0
  61. package/dist/fixer/verifier.d.ts +44 -0
  62. package/dist/fixer/verifier.js +133 -0
  63. package/dist/utils/daemon.d.ts +22 -0
  64. package/dist/utils/daemon.js +143 -0
  65. package/dist/utils/duration.d.ts +2 -0
  66. package/dist/utils/duration.js +31 -0
  67. package/dist/utils/errors.d.ts +17 -0
  68. package/dist/utils/errors.js +20 -0
  69. package/dist/utils/hash.d.ts +2 -0
  70. package/dist/utils/hash.js +18 -0
  71. package/dist/utils/http.d.ts +6 -0
  72. package/dist/utils/http.js +61 -0
  73. package/dist/utils/logger.d.ts +25 -0
  74. package/dist/utils/logger.js +85 -0
  75. package/dist/utils/process.d.ts +16 -0
  76. package/dist/utils/process.js +146 -0
  77. package/dist/watcher/index.d.ts +55 -0
  78. package/dist/watcher/index.js +234 -0
  79. package/dist/watcher/parser.d.ts +42 -0
  80. package/dist/watcher/parser.js +162 -0
  81. package/dist/watcher/patterns.d.ts +5 -0
  82. package/dist/watcher/patterns.js +92 -0
  83. package/dist/watcher/sources/command.d.ts +27 -0
  84. package/dist/watcher/sources/command.js +143 -0
  85. package/dist/watcher/sources/docker.d.ts +28 -0
  86. package/dist/watcher/sources/docker.js +183 -0
  87. package/dist/watcher/sources/file.d.ts +30 -0
  88. package/dist/watcher/sources/file.js +177 -0
  89. package/dist/watcher/sources/types.d.ts +27 -0
  90. package/dist/watcher/sources/types.js +1 -0
  91. package/package.json +38 -0
@@ -0,0 +1,92 @@
1
+ const BUILTIN_MATCH_PATTERNS = [
2
+ // JavaScript
3
+ /Error:/,
4
+ /TypeError:/,
5
+ /ReferenceError:/,
6
+ /SyntaxError:/,
7
+ /RangeError:/,
8
+ /URIError:/,
9
+ /EvalError:/,
10
+ // Node.js
11
+ /UnhandledPromiseRejection/,
12
+ /\bECONNREFUSED\b/,
13
+ /\bENOTFOUND\b/,
14
+ /\bETIMEDOUT\b/,
15
+ /\bEADDRINUSE\b/,
16
+ /\bEACCES\b/,
17
+ /\bEPERM\b/,
18
+ // Python
19
+ /Traceback \(most recent call last\)/,
20
+ /Exception:/,
21
+ /AssertionError:/,
22
+ // Go
23
+ /panic:/,
24
+ /fatal error:/,
25
+ /runtime error:/,
26
+ // Docker
27
+ /container is not running/i,
28
+ /unhealthy/i,
29
+ /OOMKilled/,
30
+ /no such container/i,
31
+ /connection refused/i,
32
+ // Database
33
+ /SQLSTATE\[/,
34
+ /deadlock detected/i,
35
+ /duplicate key/i,
36
+ /constraint violation/i,
37
+ // Generic
38
+ /\b(FATAL|CRITICAL|EMERGENCY)(?:[:\s]|$)/i,
39
+ ];
40
+ const BUILTIN_IGNORE_PATTERNS = [
41
+ /^(DEBUG|TRACE|VERBOSE|INFO)(?:[:\s]|$)/,
42
+ /\b(successfully|healthy|passed|completed|OK)\b/i,
43
+ ];
44
+ const REGEX_PREFIX = 'regex:';
45
+ const matchesRegex = (line, source) => {
46
+ try {
47
+ const pattern = new RegExp(source);
48
+ return pattern.test(line);
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ };
54
+ const matchesCustomPatterns = (line, patterns) => {
55
+ if (!patterns || patterns.length === 0) {
56
+ return false;
57
+ }
58
+ const lowerLine = line.toLowerCase();
59
+ return patterns.some((pattern) => {
60
+ if (pattern.startsWith(REGEX_PREFIX)) {
61
+ return matchesRegex(line, pattern.slice(REGEX_PREFIX.length));
62
+ }
63
+ return lowerLine.includes(pattern.toLowerCase());
64
+ });
65
+ };
66
+ const matchesBuiltinPatterns = (line, patterns) => patterns.some((pattern) => pattern.test(line));
67
+ const matchesErrorPattern = (line, customMatch, customIgnore) => {
68
+ if (matchesBuiltinPatterns(line, BUILTIN_IGNORE_PATTERNS) ||
69
+ matchesCustomPatterns(line, customIgnore)) {
70
+ return false;
71
+ }
72
+ return (matchesBuiltinPatterns(line, BUILTIN_MATCH_PATTERNS) ||
73
+ matchesCustomPatterns(line, customMatch));
74
+ };
75
+ const extractErrorType = (message) => {
76
+ const patterns = [
77
+ [/^(\w+Error):/, 1],
78
+ [/^(\w+Exception):/, 1],
79
+ [/(E[A-Z]{2,})(?:[\s:,]|$)/, 1],
80
+ [/^(panic):/, 1],
81
+ [/^(FATAL|CRITICAL):?/i, 1],
82
+ [/SQLSTATE\[(\w+)\]/, 1],
83
+ ];
84
+ for (const [pattern, group] of patterns) {
85
+ const match = message.match(pattern);
86
+ if (match) {
87
+ return match[group];
88
+ }
89
+ }
90
+ return 'Error';
91
+ };
92
+ export { BUILTIN_IGNORE_PATTERNS, BUILTIN_MATCH_PATTERNS, extractErrorType, matchesErrorPattern, };
@@ -0,0 +1,27 @@
1
+ import { Logger } from '../../utils/logger.js';
2
+ import type { CommandSourceConfig, LogEvent, LogSource } from './types.js';
3
+ type LoggerLike = Pick<Logger, 'warn' | 'info' | 'debug' | 'error'>;
4
+ type CommandSourceOptions = {
5
+ logger?: LoggerLike;
6
+ maxLineBuffer?: number;
7
+ };
8
+ export declare class CommandSource implements LogSource {
9
+ private readonly config;
10
+ private readonly logger;
11
+ private readonly emitter;
12
+ private readonly intervalMs;
13
+ private readonly maxLineBuffer;
14
+ private readonly seenHashes;
15
+ private timer;
16
+ private running;
17
+ private stopRequested;
18
+ constructor(config: CommandSourceConfig, options?: CommandSourceOptions);
19
+ start(): Promise<void>;
20
+ stop(): Promise<void>;
21
+ on(event: 'line', handler: (event: LogEvent) => void): void;
22
+ private schedule;
23
+ private runOnce;
24
+ private emitLine;
25
+ private executeCommand;
26
+ }
27
+ export {};
@@ -0,0 +1,143 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { Logger } from '../../utils/logger.js';
5
+ import { parseDuration } from '../../utils/duration.js';
6
+ const DEFAULT_MAX_LINE_BUFFER = 10_000;
7
+ const hashLine = (line) => createHash('sha256').update(line).digest('hex');
8
+ const normalizeLines = (output) => {
9
+ if (!output) {
10
+ return [];
11
+ }
12
+ const lines = output.split('\n').map((line) => line.replace(/\r$/, ''));
13
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
14
+ lines.pop();
15
+ }
16
+ return lines;
17
+ };
18
+ export class CommandSource {
19
+ config;
20
+ logger;
21
+ emitter;
22
+ intervalMs;
23
+ maxLineBuffer;
24
+ seenHashes = new Set();
25
+ timer = null;
26
+ running = false;
27
+ stopRequested = false;
28
+ constructor(config, options) {
29
+ this.config = config;
30
+ this.logger =
31
+ options?.logger ?? new Logger({ terminalEnabled: false, verbosity: 'normal' });
32
+ this.emitter = new EventEmitter();
33
+ this.intervalMs = parseDuration(config.interval);
34
+ const maxLineBuffer = options?.maxLineBuffer ?? DEFAULT_MAX_LINE_BUFFER;
35
+ this.maxLineBuffer = Math.max(1, maxLineBuffer);
36
+ }
37
+ async start() {
38
+ if (this.running || this.timer) {
39
+ return;
40
+ }
41
+ this.stopRequested = false;
42
+ this.schedule(0);
43
+ }
44
+ async stop() {
45
+ this.stopRequested = true;
46
+ if (this.timer) {
47
+ clearTimeout(this.timer);
48
+ this.timer = null;
49
+ }
50
+ }
51
+ on(event, handler) {
52
+ this.emitter.on(event, handler);
53
+ }
54
+ schedule(delayMs) {
55
+ if (this.stopRequested) {
56
+ return;
57
+ }
58
+ this.timer = setTimeout(() => {
59
+ this.timer = null;
60
+ void this.runOnce();
61
+ }, delayMs);
62
+ }
63
+ async runOnce() {
64
+ if (this.running || this.stopRequested) {
65
+ return;
66
+ }
67
+ this.running = true;
68
+ try {
69
+ const output = await this.executeCommand();
70
+ if (output.truncated) {
71
+ this.logger.warn(`Command output truncated: kept last ${this.maxLineBuffer} of ${output.totalLines} lines`);
72
+ }
73
+ if (output.exitCode !== 0) {
74
+ this.logger.warn(`Command '${this.config.run}' exited with code ${output.exitCode}`);
75
+ }
76
+ for (const line of output.lines) {
77
+ this.emitLine(line);
78
+ }
79
+ }
80
+ catch (error) {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ this.logger.error(`Command source '${this.config.name}' failed to run: ${message}`);
83
+ }
84
+ finally {
85
+ this.running = false;
86
+ if (!this.stopRequested) {
87
+ this.schedule(this.intervalMs);
88
+ }
89
+ }
90
+ }
91
+ emitLine(line) {
92
+ const hash = hashLine(line);
93
+ if (this.seenHashes.has(hash)) {
94
+ return;
95
+ }
96
+ this.seenHashes.add(hash);
97
+ this.emitter.emit('line', {
98
+ source: this.config.name,
99
+ line,
100
+ timestamp: new Date(),
101
+ });
102
+ }
103
+ async executeCommand() {
104
+ return await new Promise((resolve, reject) => {
105
+ const child = spawn(this.config.run, {
106
+ shell: true,
107
+ stdio: ['ignore', 'pipe', 'pipe'],
108
+ });
109
+ let stdout = '';
110
+ let stderr = '';
111
+ if (child.stdout) {
112
+ child.stdout.on('data', (data) => {
113
+ stdout += String(data);
114
+ });
115
+ }
116
+ if (child.stderr) {
117
+ child.stderr.on('data', (data) => {
118
+ stderr += String(data);
119
+ });
120
+ }
121
+ child.on('error', (error) => {
122
+ reject(error);
123
+ });
124
+ child.on('close', (code) => {
125
+ const combined = stdout && stderr ? `${stdout}\n${stderr}` : stdout || stderr;
126
+ const lines = normalizeLines(combined);
127
+ const totalLines = lines.length;
128
+ let truncated = false;
129
+ let keptLines = lines;
130
+ if (lines.length > this.maxLineBuffer) {
131
+ truncated = true;
132
+ keptLines = lines.slice(-this.maxLineBuffer);
133
+ }
134
+ resolve({
135
+ lines: keptLines,
136
+ totalLines,
137
+ truncated,
138
+ exitCode: code ?? -1,
139
+ });
140
+ });
141
+ });
142
+ }
143
+ }
@@ -0,0 +1,28 @@
1
+ import { Logger } from '../../utils/logger.js';
2
+ import type { DockerSourceConfig, LogEvent, LogSource } from './types.js';
3
+ type LoggerLike = Pick<Logger, 'warn' | 'info' | 'debug' | 'error'>;
4
+ type DockerSourceOptions = {
5
+ logger?: LoggerLike;
6
+ dockerCommand?: string;
7
+ };
8
+ export declare class DockerSource implements LogSource {
9
+ private readonly config;
10
+ private readonly logger;
11
+ private readonly emitter;
12
+ private readonly dockerCommand;
13
+ private child;
14
+ private reconnectTimer;
15
+ private stopRequested;
16
+ private backoffMs;
17
+ private partialLine;
18
+ private lastCheckpoint;
19
+ constructor(config: DockerSourceConfig, options?: DockerSourceOptions);
20
+ start(): Promise<void>;
21
+ stop(): Promise<void>;
22
+ on(event: 'line', handler: (event: LogEvent) => void): void;
23
+ private connect;
24
+ private scheduleReconnect;
25
+ private processChunk;
26
+ private handleLine;
27
+ }
28
+ export {};
@@ -0,0 +1,183 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { spawn } from 'node:child_process';
3
+ import { Logger } from '../../utils/logger.js';
4
+ const BACKOFF_INITIAL_MS = 1000;
5
+ const BACKOFF_MAX_MS = 60_000;
6
+ const KILL_GRACE_MS = 5000;
7
+ const DOCKER_TIMESTAMP = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?Z$/;
8
+ const normalizeDockerTimestamp = (value) => {
9
+ const match = value.match(DOCKER_TIMESTAMP);
10
+ if (!match) {
11
+ return null;
12
+ }
13
+ const base = match[1];
14
+ const fraction = match[2];
15
+ if (!fraction) {
16
+ return `${base}Z`;
17
+ }
18
+ let digits = fraction.slice(1);
19
+ if (digits.length > 3) {
20
+ digits = digits.slice(0, 3);
21
+ }
22
+ else if (digits.length < 3) {
23
+ digits = digits.padEnd(3, '0');
24
+ }
25
+ return `${base}.${digits}Z`;
26
+ };
27
+ const parseDockerLine = (line) => {
28
+ const trimmed = line.trimEnd();
29
+ if (!trimmed) {
30
+ return null;
31
+ }
32
+ const spaceIndex = trimmed.indexOf(' ');
33
+ if (spaceIndex <= 0) {
34
+ return null;
35
+ }
36
+ const rawTimestamp = trimmed.slice(0, spaceIndex);
37
+ const message = trimmed.slice(spaceIndex + 1);
38
+ const normalized = normalizeDockerTimestamp(rawTimestamp);
39
+ if (!normalized) {
40
+ return null;
41
+ }
42
+ const timestamp = new Date(normalized);
43
+ if (Number.isNaN(timestamp.getTime())) {
44
+ return null;
45
+ }
46
+ return { timestamp, message, checkpoint: normalized };
47
+ };
48
+ export class DockerSource {
49
+ config;
50
+ logger;
51
+ emitter;
52
+ dockerCommand;
53
+ child = null;
54
+ reconnectTimer = null;
55
+ stopRequested = false;
56
+ backoffMs = BACKOFF_INITIAL_MS;
57
+ partialLine = '';
58
+ lastCheckpoint = null;
59
+ constructor(config, options) {
60
+ this.config = config;
61
+ this.logger =
62
+ options?.logger ?? new Logger({ terminalEnabled: false, verbosity: 'normal' });
63
+ this.emitter = new EventEmitter();
64
+ this.dockerCommand = options?.dockerCommand ?? 'docker';
65
+ }
66
+ async start() {
67
+ if (this.child || this.reconnectTimer) {
68
+ return;
69
+ }
70
+ this.stopRequested = false;
71
+ this.connect();
72
+ }
73
+ async stop() {
74
+ this.stopRequested = true;
75
+ if (this.reconnectTimer) {
76
+ clearTimeout(this.reconnectTimer);
77
+ this.reconnectTimer = null;
78
+ }
79
+ if (this.child) {
80
+ const child = this.child;
81
+ this.child = null;
82
+ child.kill('SIGTERM');
83
+ const killTimer = setTimeout(() => {
84
+ if (child.exitCode === null && child.signalCode === null) {
85
+ child.kill('SIGKILL');
86
+ }
87
+ }, KILL_GRACE_MS);
88
+ child.once('close', () => clearTimeout(killTimer));
89
+ }
90
+ }
91
+ on(event, handler) {
92
+ this.emitter.on(event, handler);
93
+ }
94
+ connect() {
95
+ const args = ['logs', '-f', '--timestamps'];
96
+ if (this.lastCheckpoint) {
97
+ args.push(`--since=${this.lastCheckpoint}`);
98
+ }
99
+ args.push(this.config.container);
100
+ this.logger.info(`Connecting to docker logs for ${this.config.container} (since=${this.lastCheckpoint ?? 'beginning'})`);
101
+ const child = spawn(this.dockerCommand, args, {
102
+ stdio: ['ignore', 'pipe', 'pipe'],
103
+ });
104
+ this.child = child;
105
+ this.partialLine = '';
106
+ if (child.stdout) {
107
+ child.stdout.on('data', (data) => {
108
+ this.processChunk(String(data));
109
+ });
110
+ }
111
+ else {
112
+ this.logger.warn('Docker logs stdout stream unavailable');
113
+ }
114
+ if (child.stderr) {
115
+ child.stderr.on('data', (data) => {
116
+ const text = String(data).trim();
117
+ if (text) {
118
+ this.logger.warn(`Docker logs stderr: ${text}`);
119
+ }
120
+ });
121
+ }
122
+ child.on('close', (code, signal) => {
123
+ if (this.child === child) {
124
+ this.child = null;
125
+ }
126
+ if (this.stopRequested) {
127
+ return;
128
+ }
129
+ this.logger.warn(`Docker logs exited for ${this.config.container} (code=${code ?? 'n/a'}, signal=${signal ?? 'n/a'})`);
130
+ this.scheduleReconnect();
131
+ });
132
+ child.on('error', (error) => {
133
+ if (this.child === child) {
134
+ this.child = null;
135
+ }
136
+ if (this.stopRequested) {
137
+ return;
138
+ }
139
+ this.logger.error(`Docker logs error for ${this.config.container}: ${error.message}`);
140
+ this.scheduleReconnect();
141
+ });
142
+ }
143
+ scheduleReconnect() {
144
+ if (this.reconnectTimer || this.stopRequested) {
145
+ return;
146
+ }
147
+ const delay = this.backoffMs;
148
+ this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
149
+ this.logger.info(`Reconnecting to docker logs for ${this.config.container} in ${delay}ms`);
150
+ this.reconnectTimer = setTimeout(() => {
151
+ this.reconnectTimer = null;
152
+ if (!this.stopRequested) {
153
+ this.connect();
154
+ }
155
+ }, delay);
156
+ }
157
+ processChunk(chunk) {
158
+ const combined = `${this.partialLine}${chunk}`;
159
+ const lines = combined.split('\n');
160
+ this.partialLine = lines.pop() ?? '';
161
+ for (const rawLine of lines) {
162
+ this.handleLine(rawLine.replace(/\r$/, ''));
163
+ }
164
+ }
165
+ handleLine(line) {
166
+ const parsed = parseDockerLine(line);
167
+ if (parsed) {
168
+ this.lastCheckpoint = parsed.checkpoint;
169
+ this.backoffMs = BACKOFF_INITIAL_MS;
170
+ this.emitter.emit('line', {
171
+ source: this.config.name,
172
+ line: parsed.message,
173
+ timestamp: parsed.timestamp,
174
+ });
175
+ return;
176
+ }
177
+ this.emitter.emit('line', {
178
+ source: this.config.name,
179
+ line,
180
+ timestamp: new Date(),
181
+ });
182
+ }
183
+ }
@@ -0,0 +1,30 @@
1
+ import { Logger } from '../../utils/logger.js';
2
+ import type { FileSourceConfig, LogEvent, LogSource } from './types.js';
3
+ type LoggerLike = Pick<Logger, 'warn' | 'info' | 'debug' | 'error'>;
4
+ type FileSourceOptions = {
5
+ logger?: LoggerLike;
6
+ };
7
+ export declare class FileSource implements LogSource {
8
+ private readonly config;
9
+ private readonly filePath;
10
+ private readonly logger;
11
+ private readonly emitter;
12
+ private watcher;
13
+ private position;
14
+ private partialLine;
15
+ private reading;
16
+ private queued;
17
+ private pollTimer;
18
+ private lastMtimeMs;
19
+ private lastInode;
20
+ private forceRead;
21
+ constructor(config: FileSourceConfig, options?: FileSourceOptions);
22
+ start(): Promise<void>;
23
+ stop(): Promise<void>;
24
+ on(event: 'line', handler: (event: LogEvent) => void): void;
25
+ private queueRead;
26
+ private readLoop;
27
+ private readNewLines;
28
+ private processChunk;
29
+ }
30
+ export {};
@@ -0,0 +1,177 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { EventEmitter } from 'node:events';
4
+ import chokidar from 'chokidar';
5
+ import { Logger } from '../../utils/logger.js';
6
+ export class FileSource {
7
+ config;
8
+ filePath;
9
+ logger;
10
+ emitter;
11
+ watcher = null;
12
+ position = 0;
13
+ partialLine = '';
14
+ reading = false;
15
+ queued = false;
16
+ pollTimer = null;
17
+ lastMtimeMs = null;
18
+ lastInode = null;
19
+ forceRead = false;
20
+ constructor(config, options) {
21
+ this.config = config;
22
+ this.filePath = path.resolve(config.path);
23
+ this.logger =
24
+ options?.logger ?? new Logger({ terminalEnabled: false, verbosity: 'normal' });
25
+ this.emitter = new EventEmitter();
26
+ }
27
+ async start() {
28
+ if (this.watcher) {
29
+ return;
30
+ }
31
+ const exists = fs.existsSync(this.filePath);
32
+ if (exists) {
33
+ const stats = fs.statSync(this.filePath);
34
+ const now = Date.now();
35
+ const RECENT_WINDOW_MS = 1000;
36
+ const STARTUP_READ_MAX_BYTES = 64 * 1024;
37
+ const recentWrite = now - stats.mtimeMs <= RECENT_WINDOW_MS;
38
+ const readFromStart = stats.size > 0 && stats.size <= STARTUP_READ_MAX_BYTES && recentWrite;
39
+ this.position = readFromStart ? 0 : stats.size;
40
+ this.lastMtimeMs = stats.mtimeMs;
41
+ this.lastInode = typeof stats.ino === 'number' ? stats.ino : null;
42
+ }
43
+ else {
44
+ this.position = 0;
45
+ this.logger.warn(`Log file not found: ${this.filePath}. Waiting for creation.`);
46
+ }
47
+ const targets = exists
48
+ ? [this.filePath]
49
+ : [this.filePath, path.dirname(this.filePath)];
50
+ this.watcher = chokidar.watch(targets, {
51
+ ignoreInitial: true,
52
+ usePolling: true,
53
+ interval: 100,
54
+ });
55
+ const watcher = this.watcher;
56
+ const readyPromise = new Promise((resolve, reject) => {
57
+ watcher.once('ready', () => resolve());
58
+ watcher.once('error', (error) => reject(error));
59
+ });
60
+ watcher.on('add', (eventPath) => {
61
+ if (path.resolve(eventPath) !== this.filePath) {
62
+ return;
63
+ }
64
+ this.queueRead(true);
65
+ });
66
+ watcher.on('change', (eventPath) => {
67
+ if (path.resolve(eventPath) !== this.filePath) {
68
+ return;
69
+ }
70
+ this.queueRead(true);
71
+ });
72
+ await readyPromise;
73
+ this.pollTimer = setInterval(() => this.queueRead(), 200);
74
+ }
75
+ async stop() {
76
+ if (!this.watcher) {
77
+ return;
78
+ }
79
+ await this.watcher.close();
80
+ this.watcher = null;
81
+ if (this.pollTimer) {
82
+ clearInterval(this.pollTimer);
83
+ this.pollTimer = null;
84
+ }
85
+ }
86
+ on(event, handler) {
87
+ this.emitter.on(event, handler);
88
+ }
89
+ queueRead(force = false) {
90
+ if (force) {
91
+ this.forceRead = true;
92
+ }
93
+ if (this.reading) {
94
+ this.queued = true;
95
+ return;
96
+ }
97
+ this.reading = true;
98
+ void this.readLoop();
99
+ }
100
+ async readLoop() {
101
+ try {
102
+ do {
103
+ this.queued = false;
104
+ await this.readNewLines();
105
+ } while (this.queued);
106
+ }
107
+ finally {
108
+ this.reading = false;
109
+ }
110
+ }
111
+ async readNewLines() {
112
+ let stats = null;
113
+ try {
114
+ stats = await fs.promises.stat(this.filePath);
115
+ }
116
+ catch (error) {
117
+ const err = error;
118
+ if (err.code === 'ENOENT') {
119
+ return;
120
+ }
121
+ throw error;
122
+ }
123
+ if (!stats) {
124
+ return;
125
+ }
126
+ const inode = typeof stats.ino === 'number' ? stats.ino : null;
127
+ const mtimeMs = stats.mtimeMs;
128
+ const shouldForceRead = this.forceRead;
129
+ this.forceRead = false;
130
+ const inodeChanged = this.lastInode !== null && inode !== null && inode !== this.lastInode;
131
+ const mtimeChanged = this.lastMtimeMs !== null && mtimeMs !== this.lastMtimeMs;
132
+ if (inodeChanged ||
133
+ stats.size < this.position ||
134
+ (shouldForceRead && stats.size === this.position && this.position > 0) ||
135
+ (mtimeChanged && stats.size === this.position && this.position > 0)) {
136
+ this.position = 0;
137
+ this.partialLine = '';
138
+ }
139
+ if (stats.size === this.position) {
140
+ this.lastMtimeMs = mtimeMs;
141
+ this.lastInode = inode;
142
+ return;
143
+ }
144
+ const length = stats.size - this.position;
145
+ const handle = await fs.promises.open(this.filePath, 'r');
146
+ try {
147
+ const buffer = Buffer.alloc(length);
148
+ const { bytesRead } = await handle.read(buffer, 0, length, this.position);
149
+ if (bytesRead <= 0) {
150
+ this.lastMtimeMs = mtimeMs;
151
+ this.lastInode = inode;
152
+ return;
153
+ }
154
+ this.position += bytesRead;
155
+ const chunk = buffer.toString('utf8', 0, bytesRead);
156
+ this.processChunk(chunk);
157
+ this.lastMtimeMs = mtimeMs;
158
+ this.lastInode = inode;
159
+ }
160
+ finally {
161
+ await handle.close();
162
+ }
163
+ }
164
+ processChunk(chunk) {
165
+ const combined = `${this.partialLine}${chunk}`;
166
+ const lines = combined.split('\n');
167
+ this.partialLine = lines.pop() ?? '';
168
+ for (const rawLine of lines) {
169
+ const line = rawLine.replace(/\r$/, '');
170
+ this.emitter.emit('line', {
171
+ source: this.config.name,
172
+ line,
173
+ timestamp: new Date(),
174
+ });
175
+ }
176
+ }
177
+ }