agent-relay 2.0.37 → 2.1.1

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 (88) hide show
  1. package/dist/index.cjs +32003 -33977
  2. package/dist/src/cli/index.js +36 -51
  3. package/dist/src/cli/index.js.map +1 -1
  4. package/package.json +18 -19
  5. package/packages/api-types/package.json +1 -1
  6. package/packages/benchmark/package.json +4 -4
  7. package/packages/bridge/package.json +8 -8
  8. package/packages/cli-tester/package.json +1 -1
  9. package/packages/config/dist/project-namespace.d.ts +28 -0
  10. package/packages/config/dist/project-namespace.d.ts.map +1 -1
  11. package/packages/config/dist/project-namespace.js +42 -0
  12. package/packages/config/dist/project-namespace.js.map +1 -1
  13. package/packages/config/package.json +2 -2
  14. package/packages/config/src/project-namespace.ts +65 -0
  15. package/packages/continuity/dist/formatter.d.ts +8 -2
  16. package/packages/continuity/dist/formatter.d.ts.map +1 -1
  17. package/packages/continuity/dist/formatter.js +142 -7
  18. package/packages/continuity/dist/formatter.js.map +1 -1
  19. package/packages/continuity/dist/index.d.ts +1 -0
  20. package/packages/continuity/dist/index.d.ts.map +1 -1
  21. package/packages/continuity/dist/index.js +2 -0
  22. package/packages/continuity/dist/index.js.map +1 -1
  23. package/packages/continuity/package.json +4 -1
  24. package/packages/continuity/src/formatter.ts +175 -10
  25. package/packages/continuity/src/index.ts +3 -0
  26. package/packages/daemon/dist/enhanced-features.d.ts +2 -3
  27. package/packages/daemon/dist/enhanced-features.d.ts.map +1 -1
  28. package/packages/daemon/dist/enhanced-features.js +1 -0
  29. package/packages/daemon/dist/enhanced-features.js.map +1 -1
  30. package/packages/daemon/dist/index.d.ts +0 -2
  31. package/packages/daemon/dist/index.d.ts.map +1 -1
  32. package/packages/daemon/dist/index.js +0 -3
  33. package/packages/daemon/dist/index.js.map +1 -1
  34. package/packages/daemon/dist/server.d.ts +0 -6
  35. package/packages/daemon/dist/server.d.ts.map +1 -1
  36. package/packages/daemon/dist/server.js +20 -119
  37. package/packages/daemon/dist/server.js.map +1 -1
  38. package/packages/daemon/package.json +12 -14
  39. package/packages/daemon/src/enhanced-features.ts +4 -4
  40. package/packages/daemon/src/index.ts +0 -4
  41. package/packages/daemon/src/server.ts +19 -127
  42. package/packages/daemon/vitest.config.ts +9 -0
  43. package/packages/hooks/package.json +4 -4
  44. package/packages/mcp/package.json +3 -3
  45. package/packages/memory/package.json +2 -2
  46. package/packages/policy/package.json +2 -2
  47. package/packages/protocol/package.json +1 -1
  48. package/packages/resiliency/package.json +1 -1
  49. package/packages/sdk/package.json +2 -2
  50. package/packages/spawner/package.json +1 -1
  51. package/packages/state/package.json +1 -1
  52. package/packages/storage/dist/adapter.d.ts +5 -5
  53. package/packages/storage/dist/adapter.js +9 -9
  54. package/packages/storage/dist/adapter.js.map +1 -1
  55. package/packages/storage/package.json +2 -2
  56. package/packages/storage/src/adapter.ts +9 -9
  57. package/packages/telemetry/package.json +1 -1
  58. package/packages/trajectory/package.json +2 -2
  59. package/packages/user-directory/package.json +2 -2
  60. package/packages/utils/dist/cjs/relay-pty-path.js +111 -55
  61. package/packages/utils/dist/relay-pty-path.d.ts +17 -12
  62. package/packages/utils/dist/relay-pty-path.d.ts.map +1 -1
  63. package/packages/utils/dist/relay-pty-path.js +144 -94
  64. package/packages/utils/dist/relay-pty-path.js.map +1 -1
  65. package/packages/utils/package.json +2 -2
  66. package/packages/utils/src/relay-pty-path.test.ts +373 -0
  67. package/packages/utils/src/relay-pty-path.ts +182 -91
  68. package/packages/wrapper/package.json +6 -6
  69. package/scripts/build-cjs.mjs +2 -0
  70. package/packages/daemon/dist/migrations/index.d.ts +0 -73
  71. package/packages/daemon/dist/migrations/index.d.ts.map +0 -1
  72. package/packages/daemon/dist/migrations/index.js +0 -241
  73. package/packages/daemon/dist/migrations/index.js.map +0 -1
  74. package/packages/daemon/dist/relay-ledger.d.ts +0 -263
  75. package/packages/daemon/dist/relay-ledger.d.ts.map +0 -1
  76. package/packages/daemon/dist/relay-ledger.js +0 -538
  77. package/packages/daemon/dist/relay-ledger.js.map +0 -1
  78. package/packages/daemon/dist/relay-watchdog.d.ts +0 -125
  79. package/packages/daemon/dist/relay-watchdog.d.ts.map +0 -1
  80. package/packages/daemon/dist/relay-watchdog.js +0 -611
  81. package/packages/daemon/dist/relay-watchdog.js.map +0 -1
  82. package/packages/daemon/src/migrations/0001_initial.sql +0 -72
  83. package/packages/daemon/src/migrations/index.test.ts +0 -195
  84. package/packages/daemon/src/migrations/index.ts +0 -286
  85. package/packages/daemon/src/relay-ledger.test.ts +0 -358
  86. package/packages/daemon/src/relay-ledger.ts +0 -713
  87. package/packages/daemon/src/relay-watchdog.test.ts +0 -881
  88. package/packages/daemon/src/relay-watchdog.ts +0 -785
@@ -1,785 +0,0 @@
1
- /**
2
- * Relay Watchdog - File-based relay message detection and processing
3
- *
4
- * Monitors agent outbox directories for new relay files and processes them:
5
- * 1. Detects new files via fs.watch + periodic reconciliation
6
- * 2. Validates files (size > 0, not symlink, settled)
7
- * 3. Claims files atomically via ledger
8
- * 4. Processes and archives files
9
- *
10
- * Features:
11
- * - fsevents/inotify watchers with overflow fallback
12
- * - Configurable settle time for file stability
13
- * - Symlink rejection (security)
14
- * - Orphaned .pending file cleanup
15
- * - Crash recovery on startup
16
- */
17
-
18
- import { EventEmitter } from 'node:events';
19
- import fs from 'node:fs';
20
- import path from 'node:path';
21
- import crypto from 'node:crypto';
22
- import { RelayLedger, type RelayFileRecord, type LedgerConfig } from './relay-ledger.js';
23
- import { getBaseRelayPaths, type RelayPaths } from '@agent-relay/config/relay-file-writer';
24
-
25
- // ============================================================================
26
- // Types
27
- // ============================================================================
28
-
29
- export interface WatchdogConfig {
30
- /** Base relay paths (auto-detected if not provided) */
31
- relayPaths?: RelayPaths;
32
- /** Ledger database path (default: ~/.agent-relay/meta/ledger.sqlite) */
33
- ledgerPath?: string;
34
- /** Settle time before processing file (default: 500ms) */
35
- settleTimeMs?: number;
36
- /** Timeout for malformed/incomplete files (default: 10000ms) */
37
- malformedTimeoutMs?: number;
38
- /** Interval for periodic reconciliation (default: 30000ms) */
39
- reconcileIntervalMs?: number;
40
- /** Maximum message size in bytes (default: 1MB) */
41
- maxMessageSizeBytes?: number;
42
- /** Maximum attachment size in bytes (default: 10MB) */
43
- maxAttachmentSizeBytes?: number;
44
- /** Cleanup interval for orphaned files (default: 60000ms) */
45
- cleanupIntervalMs?: number;
46
- /** Age threshold for orphaned .pending files (default: 30000ms) */
47
- orphanedPendingAgeMs?: number;
48
- /** Archive retention in milliseconds (default: 7 days) */
49
- archiveRetentionMs?: number;
50
- /** Enable debug logging */
51
- debug?: boolean;
52
- }
53
-
54
- export interface DiscoveredFile {
55
- path: string;
56
- agentName: string;
57
- messageType: string;
58
- size: number;
59
- mtime: number;
60
- contentHash?: string;
61
- }
62
-
63
- export interface ProcessedFile {
64
- fileId: string;
65
- agentName: string;
66
- messageType: string;
67
- content: string;
68
- headers: Record<string, string>;
69
- body: string;
70
- }
71
-
72
- export interface WatchdogEvents {
73
- 'file:discovered': (file: DiscoveredFile) => void;
74
- 'file:processing': (record: RelayFileRecord) => void;
75
- 'file:delivered': (result: ProcessedFile) => void;
76
- 'file:failed': (record: RelayFileRecord, error: Error) => void;
77
- 'file:archived': (record: RelayFileRecord, archivePath: string) => void;
78
- 'watcher:overflow': (dir: string) => void;
79
- 'reconcile:complete': (stats: { discovered: number; failed: number }) => void;
80
- error: (error: Error) => void;
81
- }
82
-
83
- // ============================================================================
84
- // Constants
85
- // ============================================================================
86
-
87
- const DEFAULT_SETTLE_TIME_MS = parseInt(process.env.RELAY_SETTLE_TIME_MS ?? '500', 10);
88
- const DEFAULT_MALFORMED_TIMEOUT_MS = parseInt(process.env.RELAY_MALFORMED_TIMEOUT_MS ?? '10000', 10);
89
- const DEFAULT_RECONCILE_INTERVAL_MS = 30000;
90
- const DEFAULT_MAX_MESSAGE_SIZE_BYTES = 1024 * 1024; // 1MB
91
- const DEFAULT_MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
92
- const DEFAULT_CLEANUP_INTERVAL_MS = 60000;
93
- const DEFAULT_ORPHANED_PENDING_AGE_MS = 30000;
94
- const DEFAULT_ARCHIVE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
95
-
96
- // Files/directories to ignore
97
- const IGNORE_PATTERNS = [
98
- /^\./, // Hidden files
99
- /^\.pending$/, // Pending marker
100
- /\.tmp$/, // Temp files
101
- /~$/, // Editor backups
102
- ];
103
-
104
- // ============================================================================
105
- // RelayWatchdog Class
106
- // ============================================================================
107
-
108
- export class RelayWatchdog extends EventEmitter {
109
- private config: Required<WatchdogConfig>;
110
- private relayPaths: RelayPaths;
111
- private ledger: RelayLedger;
112
- private watchers: Map<string, fs.FSWatcher> = new Map();
113
- private pendingFiles: Map<string, NodeJS.Timeout> = new Map();
114
- private reconcileTimer?: NodeJS.Timeout;
115
- private cleanupTimer?: NodeJS.Timeout;
116
- private running = false;
117
-
118
- constructor(config: WatchdogConfig = {}) {
119
- super();
120
-
121
- this.relayPaths = config.relayPaths ?? getBaseRelayPaths();
122
-
123
- this.config = {
124
- relayPaths: this.relayPaths,
125
- ledgerPath: config.ledgerPath ?? path.join(this.relayPaths.metaDir, 'ledger.sqlite'),
126
- settleTimeMs: config.settleTimeMs ?? DEFAULT_SETTLE_TIME_MS,
127
- malformedTimeoutMs: config.malformedTimeoutMs ?? DEFAULT_MALFORMED_TIMEOUT_MS,
128
- reconcileIntervalMs: config.reconcileIntervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS,
129
- maxMessageSizeBytes: config.maxMessageSizeBytes ?? DEFAULT_MAX_MESSAGE_SIZE_BYTES,
130
- maxAttachmentSizeBytes: config.maxAttachmentSizeBytes ?? DEFAULT_MAX_ATTACHMENT_SIZE_BYTES,
131
- cleanupIntervalMs: config.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS,
132
- orphanedPendingAgeMs: config.orphanedPendingAgeMs ?? DEFAULT_ORPHANED_PENDING_AGE_MS,
133
- archiveRetentionMs: config.archiveRetentionMs ?? DEFAULT_ARCHIVE_RETENTION_MS,
134
- debug: config.debug ?? false,
135
- };
136
-
137
- // Initialize ledger
138
- this.ledger = new RelayLedger({
139
- dbPath: this.config.ledgerPath,
140
- archiveRetentionMs: this.config.archiveRetentionMs,
141
- });
142
- }
143
-
144
- /**
145
- * Resolve symlinks in relay paths for cloud/production workspace support
146
- * This ensures we work with canonical paths even when directories are symlinked
147
- */
148
- private async resolveRelayPaths(): Promise<void> {
149
- const resolvePath = async (p: string): Promise<string> => {
150
- try {
151
- const resolved = await fs.promises.realpath(p);
152
- if (resolved !== p) {
153
- this.log(`Resolved symlink: ${p} -> ${resolved}`);
154
- }
155
- return resolved;
156
- } catch {
157
- // Path doesn't exist yet, keep original
158
- return p;
159
- }
160
- };
161
-
162
- // Resolve all relay paths to canonical form
163
- this.relayPaths = {
164
- rootDir: await resolvePath(this.relayPaths.rootDir),
165
- outboxDir: await resolvePath(this.relayPaths.outboxDir),
166
- attachmentsDir: await resolvePath(this.relayPaths.attachmentsDir),
167
- metaDir: await resolvePath(this.relayPaths.metaDir),
168
- legacyOutboxDir: await resolvePath(this.relayPaths.legacyOutboxDir),
169
- };
170
- }
171
-
172
- /**
173
- * Start the watchdog
174
- */
175
- async start(): Promise<void> {
176
- if (this.running) return;
177
- this.running = true;
178
-
179
- this.log('Starting relay watchdog...');
180
-
181
- // Resolve symlinks in relay paths for cloud/production workspace support
182
- await this.resolveRelayPaths();
183
-
184
- // Ensure directories exist
185
- await this.ensureDirectories();
186
-
187
- // Crash recovery: reset processing files and reconcile
188
- const resetCount = this.ledger.resetProcessingFiles();
189
- if (resetCount > 0) {
190
- this.log(`Crash recovery: reset ${resetCount} processing files to pending`);
191
- }
192
-
193
- const reconcileResult = this.ledger.reconcileWithFilesystem();
194
- if (reconcileResult.failed > 0) {
195
- this.log(`Crash recovery: marked ${reconcileResult.failed} missing files as failed`);
196
- }
197
-
198
- // Initial scan to seed ledger
199
- await this.fullReconciliation();
200
-
201
- // Start root watcher for new agents
202
- this.startRootWatcher();
203
-
204
- // Start watchers for existing agents
205
- await this.startAgentWatchers();
206
-
207
- // Start periodic reconciliation
208
- this.reconcileTimer = setInterval(() => {
209
- this.fullReconciliation().catch(err => {
210
- this.emit('error', err);
211
- });
212
- }, this.config.reconcileIntervalMs);
213
- this.reconcileTimer.unref();
214
-
215
- // Start cleanup timer
216
- this.cleanupTimer = setInterval(() => {
217
- this.cleanupOrphanedFiles();
218
- this.ledger.cleanupArchivedRecords();
219
- }, this.config.cleanupIntervalMs);
220
- this.cleanupTimer.unref();
221
-
222
- this.log('Relay watchdog started');
223
- }
224
-
225
- /**
226
- * Stop the watchdog
227
- */
228
- async stop(): Promise<void> {
229
- if (!this.running) return;
230
- this.running = false;
231
-
232
- this.log('Stopping relay watchdog...');
233
-
234
- // Clear timers
235
- if (this.reconcileTimer) {
236
- clearInterval(this.reconcileTimer);
237
- this.reconcileTimer = undefined;
238
- }
239
- if (this.cleanupTimer) {
240
- clearInterval(this.cleanupTimer);
241
- this.cleanupTimer = undefined;
242
- }
243
-
244
- // Clear pending file timers
245
- for (const timer of this.pendingFiles.values()) {
246
- clearTimeout(timer);
247
- }
248
- this.pendingFiles.clear();
249
-
250
- // Close all watchers
251
- for (const [dir, watcher] of this.watchers) {
252
- watcher.close();
253
- this.log(`Closed watcher for: ${dir}`);
254
- }
255
- this.watchers.clear();
256
-
257
- // Close ledger
258
- this.ledger.close();
259
-
260
- this.log('Relay watchdog stopped');
261
- }
262
-
263
- /**
264
- * Get ledger statistics
265
- */
266
- getStats(): Record<string, number> {
267
- return this.ledger.getStats();
268
- }
269
-
270
- /**
271
- * Get pending file count
272
- */
273
- getPendingCount(): number {
274
- return this.ledger.getPendingFiles(1).length > 0 ? this.ledger.getStats().pending : 0;
275
- }
276
-
277
- // ==========================================================================
278
- // Directory Management
279
- // ==========================================================================
280
-
281
- private async ensureDirectories(): Promise<void> {
282
- const dirs = [
283
- this.relayPaths.outboxDir,
284
- this.relayPaths.attachmentsDir,
285
- this.relayPaths.metaDir,
286
- path.join(this.relayPaths.rootDir, 'archive'),
287
- ];
288
-
289
- for (const dir of dirs) {
290
- if (!fs.existsSync(dir)) {
291
- await fs.promises.mkdir(dir, { recursive: true });
292
- this.log(`Created directory: ${dir}`);
293
- }
294
- }
295
- }
296
-
297
- // ==========================================================================
298
- // File Watching
299
- // ==========================================================================
300
-
301
- private startRootWatcher(): void {
302
- const outboxDir = this.relayPaths.outboxDir;
303
-
304
- if (!fs.existsSync(outboxDir)) {
305
- this.log(`Outbox directory doesn't exist yet: ${outboxDir}`);
306
- return;
307
- }
308
-
309
- try {
310
- const watcher = fs.watch(outboxDir, (eventType, filename) => {
311
- if (!filename || this.shouldIgnore(filename)) return;
312
-
313
- const agentDir = path.join(outboxDir, filename);
314
-
315
- // Check if new agent directory was created
316
- if (eventType === 'rename') {
317
- this.checkAndWatchAgentDir(agentDir, filename);
318
- }
319
- });
320
-
321
- watcher.on('error', (err) => {
322
- this.log(`Root watcher error: ${err.message}`);
323
- this.emit('watcher:overflow', outboxDir);
324
- // Trigger full reconciliation on watcher error
325
- this.fullReconciliation().catch(e => this.emit('error', e));
326
- });
327
-
328
- this.watchers.set(outboxDir, watcher);
329
- this.log(`Started root watcher: ${outboxDir}`);
330
- } catch (err: any) {
331
- this.log(`Failed to start root watcher: ${err.message}`);
332
- }
333
- }
334
-
335
- private async startAgentWatchers(): Promise<void> {
336
- const outboxDir = this.relayPaths.outboxDir;
337
-
338
- if (!fs.existsSync(outboxDir)) return;
339
-
340
- const entries = await fs.promises.readdir(outboxDir, { withFileTypes: true });
341
-
342
- for (const entry of entries) {
343
- if (entry.isDirectory() && !this.shouldIgnore(entry.name)) {
344
- const agentDir = path.join(outboxDir, entry.name);
345
- this.startAgentWatcher(agentDir, entry.name);
346
- }
347
- }
348
- }
349
-
350
- private checkAndWatchAgentDir(agentDir: string, agentName: string): void {
351
- try {
352
- const stats = fs.statSync(agentDir);
353
- if (stats.isDirectory() && !this.watchers.has(agentDir)) {
354
- this.startAgentWatcher(agentDir, agentName);
355
- }
356
- } catch {
357
- // Directory might not exist yet or was deleted
358
- }
359
- }
360
-
361
- private startAgentWatcher(agentDir: string, agentName: string): void {
362
- if (this.watchers.has(agentDir)) return;
363
-
364
- try {
365
- const watcher = fs.watch(agentDir, (eventType, filename) => {
366
- if (!filename || this.shouldIgnore(filename)) return;
367
-
368
- const filePath = path.join(agentDir, filename);
369
- this.handleFileEvent(filePath, agentName, filename);
370
- });
371
-
372
- watcher.on('error', (err) => {
373
- this.log(`Agent watcher error (${agentName}): ${err.message}`);
374
- this.emit('watcher:overflow', agentDir);
375
- // Remove failed watcher and trigger reconciliation
376
- this.watchers.delete(agentDir);
377
- this.fullReconciliation().catch(e => this.emit('error', e));
378
- });
379
-
380
- this.watchers.set(agentDir, watcher);
381
- this.log(`Started agent watcher: ${agentDir}`);
382
- } catch (err: any) {
383
- this.log(`Failed to start agent watcher (${agentName}): ${err.message}`);
384
- }
385
- }
386
-
387
- // ==========================================================================
388
- // File Event Handling
389
- // ==========================================================================
390
-
391
- private handleFileEvent(filePath: string, agentName: string, messageType: string): void {
392
- // Cancel any existing settle timer for this file
393
- const existingTimer = this.pendingFiles.get(filePath);
394
- if (existingTimer) {
395
- clearTimeout(existingTimer);
396
- }
397
-
398
- // Start settle timer
399
- const timer = setTimeout(() => {
400
- this.pendingFiles.delete(filePath);
401
- this.processDiscoveredFile(filePath, agentName, messageType).catch(err => {
402
- this.emit('error', err);
403
- });
404
- }, this.config.settleTimeMs);
405
-
406
- this.pendingFiles.set(filePath, timer);
407
- }
408
-
409
- private async processDiscoveredFile(
410
- filePath: string,
411
- agentName: string,
412
- messageType: string
413
- ): Promise<void> {
414
- try {
415
- // SECURITY: Check if the file itself is a symlink BEFORE resolving
416
- // This prevents symlink attacks where an attacker creates a symlink
417
- // pointing to a file outside the agent's outbox
418
- try {
419
- const lstats = await fs.promises.lstat(filePath);
420
- if (lstats.isSymbolicLink()) {
421
- this.log(`Rejected symlink file (security): ${filePath}`);
422
- return;
423
- }
424
- } catch {
425
- // File doesn't exist - will be caught later
426
- }
427
-
428
- // Resolve symlinks in DIRECTORY path to get canonical path
429
- // (for cloud workspaces where the outbox directory may be symlinked)
430
- // Note: The file itself was already verified to NOT be a symlink above
431
- let canonicalPath: string;
432
- let originalPath: string | undefined;
433
- try {
434
- canonicalPath = await fs.promises.realpath(filePath);
435
- // Only store original if it differs (directory was symlinked)
436
- if (canonicalPath !== filePath) {
437
- originalPath = filePath;
438
- this.log(`Resolved directory symlink: ${filePath} -> ${canonicalPath}`);
439
- }
440
- } catch {
441
- // File doesn't exist or can't resolve - use original
442
- canonicalPath = filePath;
443
- }
444
-
445
- // Validate file exists and get stats (uses canonical path)
446
- const validation = await this.validateFile(canonicalPath);
447
- if (!validation.valid) {
448
- this.log(`File validation failed (${canonicalPath}): ${validation.reason}`);
449
- return;
450
- }
451
-
452
- const stats = validation.stats!;
453
-
454
- // Check if already registered (by canonical path)
455
- if (this.ledger.isFileRegistered(canonicalPath)) {
456
- this.log(`File already registered: ${canonicalPath}`);
457
- return;
458
- }
459
-
460
- // Calculate content hash for deduplication
461
- const contentHash = await this.calculateFileHash(canonicalPath);
462
-
463
- // Register in ledger with both paths
464
- // sourcePath = canonical (resolved), symlinkPath = original (if symlinked)
465
- const fileId = this.ledger.registerFile(
466
- canonicalPath,
467
- agentName,
468
- messageType,
469
- stats.size,
470
- contentHash,
471
- undefined, // fileMtimeNs
472
- undefined, // fileInode
473
- originalPath // symlinkPath (only set if it was a symlink)
474
- );
475
-
476
- const discoveredFile: DiscoveredFile = {
477
- path: canonicalPath,
478
- agentName,
479
- messageType,
480
- size: stats.size,
481
- mtime: stats.mtimeMs,
482
- contentHash,
483
- };
484
-
485
- this.emit('file:discovered', discoveredFile);
486
- this.log(`Discovered file: ${canonicalPath} (id: ${fileId})`);
487
-
488
- // Attempt to process immediately
489
- await this.processFile(fileId);
490
- } catch (err: any) {
491
- this.log(`Error processing discovered file (${filePath}): ${err.message}`);
492
- }
493
- }
494
-
495
- // ==========================================================================
496
- // File Validation
497
- // ==========================================================================
498
-
499
- private async validateFile(
500
- filePath: string
501
- ): Promise<{ valid: boolean; stats?: fs.Stats; reason?: string }> {
502
- try {
503
- // Use lstat to detect symlinks (don't follow them)
504
- const stats = await fs.promises.lstat(filePath);
505
-
506
- // Reject symlinks (security)
507
- if (stats.isSymbolicLink()) {
508
- return { valid: false, reason: 'Symlinks not allowed' };
509
- }
510
-
511
- // Must be a regular file
512
- if (!stats.isFile()) {
513
- return { valid: false, reason: 'Not a regular file' };
514
- }
515
-
516
- // Skip 0-byte files
517
- if (stats.size === 0) {
518
- return { valid: false, reason: 'Empty file (0 bytes)' };
519
- }
520
-
521
- // Check size limits
522
- if (stats.size > this.config.maxMessageSizeBytes) {
523
- return { valid: false, reason: `File too large (${stats.size} > ${this.config.maxMessageSizeBytes})` };
524
- }
525
-
526
- // Re-stat to check stability (file size hasn't changed)
527
- await new Promise(resolve => setTimeout(resolve, 50));
528
- const stats2 = await fs.promises.lstat(filePath);
529
-
530
- if (stats.size !== stats2.size || stats.mtimeMs !== stats2.mtimeMs) {
531
- return { valid: false, reason: 'File still being written' };
532
- }
533
-
534
- return { valid: true, stats };
535
- } catch (err: any) {
536
- if (err.code === 'ENOENT') {
537
- return { valid: false, reason: 'File does not exist' };
538
- }
539
- return { valid: false, reason: err.message };
540
- }
541
- }
542
-
543
- private async calculateFileHash(filePath: string): Promise<string> {
544
- const content = await fs.promises.readFile(filePath);
545
- return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
546
- }
547
-
548
- // ==========================================================================
549
- // File Processing
550
- // ==========================================================================
551
-
552
- private async processFile(fileId: string): Promise<void> {
553
- // Atomically claim the file
554
- const claimResult = this.ledger.claimFile(fileId);
555
-
556
- if (!claimResult.success) {
557
- this.log(`Failed to claim file ${fileId}: ${claimResult.reason}`);
558
- return;
559
- }
560
-
561
- const record = claimResult.record!;
562
- this.emit('file:processing', record);
563
-
564
- try {
565
- // Read file content
566
- const content = await fs.promises.readFile(record.sourcePath, 'utf-8');
567
-
568
- // Parse headers and body
569
- const { headers, body } = this.parseFileContent(content);
570
-
571
- const processedFile: ProcessedFile = {
572
- fileId: record.fileId,
573
- agentName: record.agentName,
574
- messageType: record.messageType,
575
- content,
576
- headers,
577
- body,
578
- };
579
-
580
- // Mark as delivered
581
- this.ledger.markDelivered(fileId);
582
- this.emit('file:delivered', processedFile);
583
-
584
- // Archive the file
585
- await this.archiveFile(record);
586
-
587
- this.log(`Processed file: ${record.sourcePath} (id: ${fileId})`);
588
- } catch (err: any) {
589
- this.log(`Error processing file ${fileId}: ${err.message}`);
590
- this.ledger.markFailed(fileId, err.message);
591
- this.emit('file:failed', record, err);
592
- }
593
- }
594
-
595
- private parseFileContent(content: string): { headers: Record<string, string>; body: string } {
596
- const headers: Record<string, string> = {};
597
- const lines = content.split('\n');
598
- let bodyStartIndex = 0;
599
-
600
- // Parse headers until empty line
601
- for (let i = 0; i < lines.length; i++) {
602
- const line = lines[i];
603
-
604
- // Empty line marks end of headers
605
- if (line.trim() === '') {
606
- bodyStartIndex = i + 1;
607
- break;
608
- }
609
-
610
- // Parse header: "KEY: value"
611
- const colonIndex = line.indexOf(':');
612
- if (colonIndex > 0) {
613
- const key = line.slice(0, colonIndex).trim().toUpperCase();
614
- const value = line.slice(colonIndex + 1).trim();
615
- headers[key] = value;
616
- } else {
617
- // No colon found, treat rest as body
618
- bodyStartIndex = i;
619
- break;
620
- }
621
- }
622
-
623
- const body = lines.slice(bodyStartIndex).join('\n').trim();
624
-
625
- return { headers, body };
626
- }
627
-
628
- // ==========================================================================
629
- // File Archiving
630
- // ==========================================================================
631
-
632
- private async archiveFile(record: RelayFileRecord): Promise<void> {
633
- const archiveDir = path.join(
634
- this.relayPaths.rootDir,
635
- 'archive',
636
- record.agentName,
637
- new Date().toISOString().slice(0, 10) // YYYY-MM-DD
638
- );
639
-
640
- await fs.promises.mkdir(archiveDir, { recursive: true });
641
-
642
- const archivePath = path.join(archiveDir, `${record.fileId}-${record.messageType}`);
643
-
644
- try {
645
- // Move file to archive
646
- await fs.promises.rename(record.sourcePath, archivePath);
647
- this.ledger.markArchived(record.fileId, archivePath);
648
- this.emit('file:archived', record, archivePath);
649
- } catch (err: any) {
650
- // If rename fails (cross-device), copy and delete
651
- if (err.code === 'EXDEV') {
652
- await fs.promises.copyFile(record.sourcePath, archivePath);
653
- await fs.promises.unlink(record.sourcePath);
654
- this.ledger.markArchived(record.fileId, archivePath);
655
- this.emit('file:archived', record, archivePath);
656
- } else {
657
- throw err;
658
- }
659
- }
660
- }
661
-
662
- // ==========================================================================
663
- // Reconciliation
664
- // ==========================================================================
665
-
666
- private async fullReconciliation(): Promise<void> {
667
- const outboxDir = this.relayPaths.outboxDir;
668
- let discovered = 0;
669
- let failed = 0;
670
-
671
- if (!fs.existsSync(outboxDir)) {
672
- return;
673
- }
674
-
675
- try {
676
- // Scan all agent directories
677
- const agents = await fs.promises.readdir(outboxDir, { withFileTypes: true });
678
-
679
- for (const agent of agents) {
680
- if (!agent.isDirectory() || this.shouldIgnore(agent.name)) continue;
681
-
682
- const agentDir = path.join(outboxDir, agent.name);
683
-
684
- // Ensure watcher exists for this agent
685
- if (!this.watchers.has(agentDir)) {
686
- this.startAgentWatcher(agentDir, agent.name);
687
- }
688
-
689
- // Scan agent's outbox
690
- try {
691
- const files = await fs.promises.readdir(agentDir, { withFileTypes: true });
692
-
693
- for (const file of files) {
694
- if (!file.isFile() || this.shouldIgnore(file.name)) continue;
695
-
696
- const filePath = path.join(agentDir, file.name);
697
-
698
- // Skip if already registered
699
- if (this.ledger.isFileRegistered(filePath)) continue;
700
-
701
- try {
702
- await this.processDiscoveredFile(filePath, agent.name, file.name);
703
- discovered++;
704
- } catch {
705
- failed++;
706
- }
707
- }
708
- } catch (err: any) {
709
- this.log(`Failed to scan agent directory (${agent.name}): ${err.message}`);
710
- }
711
- }
712
-
713
- // Process any pending files from ledger
714
- const pendingFiles = this.ledger.getPendingFiles();
715
- for (const record of pendingFiles) {
716
- await this.processFile(record.fileId);
717
- }
718
-
719
- this.emit('reconcile:complete', { discovered, failed });
720
- } catch (err: any) {
721
- this.log(`Reconciliation error: ${err.message}`);
722
- this.emit('error', err);
723
- }
724
- }
725
-
726
- // ==========================================================================
727
- // Cleanup
728
- // ==========================================================================
729
-
730
- private async cleanupOrphanedFiles(): Promise<void> {
731
- const outboxDir = this.relayPaths.outboxDir;
732
- const now = Date.now();
733
-
734
- if (!fs.existsSync(outboxDir)) return;
735
-
736
- try {
737
- const agents = await fs.promises.readdir(outboxDir, { withFileTypes: true });
738
-
739
- for (const agent of agents) {
740
- if (!agent.isDirectory()) continue;
741
-
742
- const agentDir = path.join(outboxDir, agent.name);
743
- const files = await fs.promises.readdir(agentDir);
744
-
745
- for (const file of files) {
746
- // Clean up .pending files older than threshold
747
- if (file.endsWith('.pending')) {
748
- const filePath = path.join(agentDir, file);
749
- try {
750
- const stats = await fs.promises.stat(filePath);
751
- if (now - stats.mtimeMs > this.config.orphanedPendingAgeMs) {
752
- await fs.promises.unlink(filePath);
753
- this.log(`Cleaned up orphaned .pending file: ${filePath}`);
754
- }
755
- } catch {
756
- // File may have been deleted
757
- }
758
- }
759
- }
760
- }
761
- } catch (err: any) {
762
- this.log(`Cleanup error: ${err.message}`);
763
- }
764
- }
765
-
766
- // ==========================================================================
767
- // Utilities
768
- // ==========================================================================
769
-
770
- private shouldIgnore(filename: string): boolean {
771
- return IGNORE_PATTERNS.some(pattern => pattern.test(filename));
772
- }
773
-
774
- private log(message: string): void {
775
- if (this.config.debug) {
776
- console.log(`[relay-watchdog] ${message}`);
777
- }
778
- }
779
- }
780
-
781
- // Type augmentation for EventEmitter
782
- export interface RelayWatchdog {
783
- on<K extends keyof WatchdogEvents>(event: K, listener: WatchdogEvents[K]): this;
784
- emit<K extends keyof WatchdogEvents>(event: K, ...args: Parameters<WatchdogEvents[K]>): boolean;
785
- }