@mariozechner/pi-mom 0.69.0 → 0.70.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.70.0] - 2026-04-23
4
+
5
+ ### Fixed
6
+
7
+ - Fixed Mom event-directory `fs.watch` error handling to retry after transient watcher failures such as `EMFILE`, avoiding startup crashes ([#3564](https://github.com/badlogic/pi-mono/issues/3564))
8
+
3
9
  ## [0.69.0] - 2026-04-22
4
10
 
5
11
  ### Breaking Changes
package/dist/events.d.ts CHANGED
@@ -26,7 +26,9 @@ export declare class EventsWatcher {
26
26
  private debounceTimers;
27
27
  private startTime;
28
28
  private watcher;
29
+ private watcherRetryTimer;
29
30
  private knownFiles;
31
+ private stopped;
30
32
  constructor(eventsDir: string, slack: SlackBot);
31
33
  /**
32
34
  * Start watching for events. Call this after SlackBot is ready.
@@ -36,8 +38,12 @@ export declare class EventsWatcher {
36
38
  * Stop watching and cancel all scheduled events.
37
39
  */
38
40
  stop(): void;
41
+ private startFsWatcher;
42
+ private handleFsWatcherError;
43
+ private scheduleFsWatcherRetry;
39
44
  private debounce;
40
45
  private scanExisting;
46
+ private rescanExisting;
41
47
  private handleFileChange;
42
48
  private handleDelete;
43
49
  private cancelScheduled;
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAc,MAAM,YAAY,CAAC;AAMvD,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,QAAQ,GAAG,cAAc,GAAG,YAAY,GAAG,aAAa,CAAC;AAUrE,qBAAa,aAAa;IASxB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,KAAK;IATd,OAAO,CAAC,MAAM,CAA0C;IACxD,OAAO,CAAC,KAAK,CAAgC;IAC7C,OAAO,CAAC,cAAc,CAA0C;IAChE,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,UAAU,CAA0B;IAE5C,YACS,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,QAAQ,EAGvB;IAED;;OAEG;IACH,KAAK,IAAI,IAAI,CAkBZ;IAED;;OAEG;IACH,IAAI,IAAI,IAAI,CA2BX;IAED,OAAO,CAAC,QAAQ;IAchB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,eAAe;YAcT,UAAU;IA0CxB,OAAO,CAAC,UAAU;IAqClB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,cAAc;IAiBtB,OAAO,CAAC,OAAO;IAyCf,OAAO,CAAC,UAAU;IAalB,OAAO,CAAC,KAAK;CAGb;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,aAAa,CAGxF","sourcesContent":["import { Cron } from \"croner\";\nimport { existsSync, type FSWatcher, mkdirSync, readdirSync, statSync, unlinkSync, watch } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\nimport type { SlackBot, SlackEvent } from \"./slack.js\";\n\n// ============================================================================\n// Event Types\n// ============================================================================\n\nexport interface ImmediateEvent {\n\ttype: \"immediate\";\n\tchannelId: string;\n\ttext: string;\n}\n\nexport interface OneShotEvent {\n\ttype: \"one-shot\";\n\tchannelId: string;\n\ttext: string;\n\tat: string; // ISO 8601 with timezone offset\n}\n\nexport interface PeriodicEvent {\n\ttype: \"periodic\";\n\tchannelId: string;\n\ttext: string;\n\tschedule: string; // cron syntax\n\ttimezone: string; // IANA timezone\n}\n\nexport type MomEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\n// ============================================================================\n// EventsWatcher\n// ============================================================================\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n\tprivate timers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate crons: Map<string, Cron> = new Map();\n\tprivate debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate startTime: number;\n\tprivate watcher: FSWatcher | null = null;\n\tprivate knownFiles: Set<string> = new Set();\n\n\tconstructor(\n\t\tprivate eventsDir: string,\n\t\tprivate slack: SlackBot,\n\t) {\n\t\tthis.startTime = Date.now();\n\t}\n\n\t/**\n\t * Start watching for events. Call this after SlackBot is ready.\n\t */\n\tstart(): void {\n\t\t// Ensure events directory exists\n\t\tif (!existsSync(this.eventsDir)) {\n\t\t\tmkdirSync(this.eventsDir, { recursive: true });\n\t\t}\n\n\t\tlog.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n\t\t// Scan existing files\n\t\tthis.scanExisting();\n\n\t\t// Watch for changes\n\t\tthis.watcher = watch(this.eventsDir, (_eventType, filename) => {\n\t\t\tif (!filename || !filename.endsWith(\".json\")) return;\n\t\t\tthis.debounce(filename, () => this.handleFileChange(filename));\n\t\t});\n\n\t\tlog.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n\t}\n\n\t/**\n\t * Stop watching and cancel all scheduled events.\n\t */\n\tstop(): void {\n\t\t// Stop fs watcher\n\t\tif (this.watcher) {\n\t\t\tthis.watcher.close();\n\t\t\tthis.watcher = null;\n\t\t}\n\n\t\t// Cancel all debounce timers\n\t\tfor (const timer of this.debounceTimers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.debounceTimers.clear();\n\n\t\t// Cancel all scheduled timers\n\t\tfor (const timer of this.timers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.timers.clear();\n\n\t\t// Cancel all cron jobs\n\t\tfor (const cron of this.crons.values()) {\n\t\t\tcron.stop();\n\t\t}\n\t\tthis.crons.clear();\n\n\t\tthis.knownFiles.clear();\n\t\tlog.logInfo(\"Events watcher stopped\");\n\t}\n\n\tprivate debounce(filename: string, fn: () => void): void {\n\t\tconst existing = this.debounceTimers.get(filename);\n\t\tif (existing) {\n\t\t\tclearTimeout(existing);\n\t\t}\n\t\tthis.debounceTimers.set(\n\t\t\tfilename,\n\t\t\tsetTimeout(() => {\n\t\t\t\tthis.debounceTimers.delete(filename);\n\t\t\t\tfn();\n\t\t\t}, DEBOUNCE_MS),\n\t\t);\n\t}\n\n\tprivate scanExisting(): void {\n\t\tlet files: string[];\n\t\ttry {\n\t\t\tfiles = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Failed to read events directory\", String(err));\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const filename of files) {\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate handleFileChange(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\tif (!existsSync(filePath)) {\n\t\t\t// File was deleted\n\t\t\tthis.handleDelete(filename);\n\t\t} else if (this.knownFiles.has(filename)) {\n\t\t\t// File was modified - cancel existing and re-schedule\n\t\t\tthis.cancelScheduled(filename);\n\t\t\tthis.handleFile(filename);\n\t\t} else {\n\t\t\t// New file\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate handleDelete(filename: string): void {\n\t\tif (!this.knownFiles.has(filename)) return;\n\n\t\tlog.logInfo(`Event file deleted: ${filename}`);\n\t\tthis.cancelScheduled(filename);\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate cancelScheduled(filename: string): void {\n\t\tconst timer = this.timers.get(filename);\n\t\tif (timer) {\n\t\t\tclearTimeout(timer);\n\t\t\tthis.timers.delete(filename);\n\t\t}\n\n\t\tconst cron = this.crons.get(filename);\n\t\tif (cron) {\n\t\t\tcron.stop();\n\t\t\tthis.crons.delete(filename);\n\t\t}\n\t}\n\n\tprivate async handleFile(filename: string): Promise<void> {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Parse with retries\n\t\tlet event: MomEvent | null = null;\n\t\tlet lastError: Error | null = null;\n\n\t\tfor (let i = 0; i < MAX_RETRIES; i++) {\n\t\t\ttry {\n\t\t\t\tconst content = await readFile(filePath, \"utf-8\");\n\t\t\t\tevent = this.parseEvent(content, filename);\n\t\t\t\tbreak;\n\t\t\t} catch (err) {\n\t\t\t\tlastError = err instanceof Error ? err : new Error(String(err));\n\t\t\t\tif (i < MAX_RETRIES - 1) {\n\t\t\t\t\tawait this.sleep(RETRY_BASE_MS * 2 ** i);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (!event) {\n\t\t\tlog.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.knownFiles.add(filename);\n\n\t\t// Schedule based on type\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tthis.handleImmediate(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tthis.handleOneShot(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tthis.handlePeriodic(filename, event);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate parseEvent(content: string, filename: string): MomEvent | null {\n\t\tconst data = JSON.parse(content);\n\n\t\tif (!data.type || !data.channelId || !data.text) {\n\t\t\tthrow new Error(`Missing required fields (type, channelId, text) in ${filename}`);\n\t\t}\n\n\t\tswitch (data.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\treturn { type: \"immediate\", channelId: data.channelId, text: data.text };\n\n\t\t\tcase \"one-shot\":\n\t\t\t\tif (!data.at) {\n\t\t\t\t\tthrow new Error(`Missing 'at' field for one-shot event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn { type: \"one-shot\", channelId: data.channelId, text: data.text, at: data.at };\n\n\t\t\tcase \"periodic\":\n\t\t\t\tif (!data.schedule) {\n\t\t\t\t\tthrow new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\tif (!data.timezone) {\n\t\t\t\t\tthrow new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"periodic\",\n\t\t\t\t\tchannelId: data.channelId,\n\t\t\t\t\ttext: data.text,\n\t\t\t\t\tschedule: data.schedule,\n\t\t\t\t\ttimezone: data.timezone,\n\t\t\t\t};\n\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown event type '${data.type}' in ${filename}`);\n\t\t}\n\t}\n\n\tprivate handleImmediate(filename: string, event: ImmediateEvent): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Check if stale (created before harness started)\n\t\ttry {\n\t\t\tconst stat = statSync(filePath);\n\t\t\tif (stat.mtimeMs < this.startTime) {\n\t\t\t\tlog.logInfo(`Stale immediate event, deleting: ${filename}`);\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch {\n\t\t\t// File may have been deleted\n\t\t\treturn;\n\t\t}\n\n\t\tlog.logInfo(`Executing immediate event: ${filename}`);\n\t\tthis.execute(filename, event);\n\t}\n\n\tprivate handleOneShot(filename: string, event: OneShotEvent): void {\n\t\tconst atTime = new Date(event.at).getTime();\n\t\tconst now = Date.now();\n\n\t\tif (atTime <= now) {\n\t\t\t// Past - delete without executing\n\t\t\tlog.logInfo(`One-shot event in the past, deleting: ${filename}`);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tconst delay = atTime - now;\n\t\tlog.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);\n\n\t\tconst timer = setTimeout(() => {\n\t\t\tthis.timers.delete(filename);\n\t\t\tlog.logInfo(`Executing one-shot event: ${filename}`);\n\t\t\tthis.execute(filename, event);\n\t\t}, delay);\n\n\t\tthis.timers.set(filename, timer);\n\t}\n\n\tprivate handlePeriodic(filename: string, event: PeriodicEvent): void {\n\t\ttry {\n\t\t\tconst cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n\t\t\t\tlog.logInfo(`Executing periodic event: ${filename}`);\n\t\t\t\tthis.execute(filename, event, false); // Don't delete periodic events\n\t\t\t});\n\n\t\t\tthis.crons.set(filename, cron);\n\n\t\t\tconst next = cron.nextRun();\n\t\t\tlog.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`);\n\t\t} catch (err) {\n\t\t\tlog.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n\t\t\tthis.deleteFile(filename);\n\t\t}\n\t}\n\n\tprivate execute(filename: string, event: MomEvent, deleteAfter: boolean = true): void {\n\t\t// Format the message\n\t\tlet scheduleInfo: string;\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tscheduleInfo = \"immediate\";\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tscheduleInfo = event.at;\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tscheduleInfo = event.schedule;\n\t\t\t\tbreak;\n\t\t}\n\n\t\tconst message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;\n\n\t\t// Create synthetic SlackEvent\n\t\tconst syntheticEvent: SlackEvent = {\n\t\t\ttype: \"mention\",\n\t\t\tchannel: event.channelId,\n\t\t\tuser: \"EVENT\",\n\t\t\ttext: message,\n\t\t\tts: Date.now().toString(),\n\t\t};\n\n\t\t// Enqueue for processing\n\t\tconst enqueued = this.slack.enqueueEvent(syntheticEvent);\n\n\t\tif (enqueued && deleteAfter) {\n\t\t\t// Delete file after successful enqueue (immediate and one-shot)\n\t\t\tthis.deleteFile(filename);\n\t\t} else if (!enqueued) {\n\t\t\tlog.logWarning(`Event queue full, discarded: ${filename}`);\n\t\t\t// Still delete immediate/one-shot even if discarded\n\t\t\tif (deleteAfter) {\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate deleteFile(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\t\ttry {\n\t\t\tunlinkSync(filePath);\n\t\t} catch (err) {\n\t\t\t// ENOENT is fine (file already deleted), other errors are warnings\n\t\t\tif (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n\t\t\t\tlog.logWarning(`Failed to delete event file: ${filename}`, String(err));\n\t\t\t}\n\t\t}\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate sleep(ms: number): Promise<void> {\n\t\treturn new Promise((resolve) => setTimeout(resolve, ms));\n\t}\n}\n\n/**\n * Create and start an events watcher.\n */\nexport function createEventsWatcher(workspaceDir: string, slack: SlackBot): EventsWatcher {\n\tconst eventsDir = join(workspaceDir, \"events\");\n\treturn new EventsWatcher(eventsDir, slack);\n}\n"]}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,QAAQ,EAAc,MAAM,YAAY,CAAC;AAMvD,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,QAAQ,GAAG,cAAc,GAAG,YAAY,GAAG,aAAa,CAAC;AAUrE,qBAAa,aAAa;IAWxB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,KAAK;IAXd,OAAO,CAAC,MAAM,CAA0C;IACxD,OAAO,CAAC,KAAK,CAAgC;IAC7C,OAAO,CAAC,cAAc,CAA0C;IAChE,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,iBAAiB,CAA+B;IACxD,OAAO,CAAC,UAAU,CAA0B;IAC5C,OAAO,CAAC,OAAO,CAAQ;IAEvB,YACS,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,QAAQ,EAGvB;IAED;;OAEG;IACH,KAAK,IAAI,IAAI,CAgBZ;IAED;;OAEG;IACH,IAAI,IAAI,IAAI,CA+BX;IAED,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,sBAAsB;IAiB9B,OAAO,CAAC,QAAQ;IAchB,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,cAAc;IAoBtB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,eAAe;YAcT,UAAU;IA0CxB,OAAO,CAAC,UAAU;IAqClB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IAuBrB,OAAO,CAAC,cAAc;IAiBtB,OAAO,CAAC,OAAO;IAyCf,OAAO,CAAC,UAAU;IAalB,OAAO,CAAC,KAAK;CAGb;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,aAAa,CAGxF","sourcesContent":["import { Cron } from \"croner\";\nimport { existsSync, type FSWatcher, mkdirSync, readdirSync, statSync, unlinkSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from \"./fs-watch.js\";\nimport * as log from \"./log.js\";\nimport type { SlackBot, SlackEvent } from \"./slack.js\";\n\n// ============================================================================\n// Event Types\n// ============================================================================\n\nexport interface ImmediateEvent {\n\ttype: \"immediate\";\n\tchannelId: string;\n\ttext: string;\n}\n\nexport interface OneShotEvent {\n\ttype: \"one-shot\";\n\tchannelId: string;\n\ttext: string;\n\tat: string; // ISO 8601 with timezone offset\n}\n\nexport interface PeriodicEvent {\n\ttype: \"periodic\";\n\tchannelId: string;\n\ttext: string;\n\tschedule: string; // cron syntax\n\ttimezone: string; // IANA timezone\n}\n\nexport type MomEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\n// ============================================================================\n// EventsWatcher\n// ============================================================================\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n\tprivate timers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate crons: Map<string, Cron> = new Map();\n\tprivate debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate startTime: number;\n\tprivate watcher: FSWatcher | null = null;\n\tprivate watcherRetryTimer: NodeJS.Timeout | null = null;\n\tprivate knownFiles: Set<string> = new Set();\n\tprivate stopped = true;\n\n\tconstructor(\n\t\tprivate eventsDir: string,\n\t\tprivate slack: SlackBot,\n\t) {\n\t\tthis.startTime = Date.now();\n\t}\n\n\t/**\n\t * Start watching for events. Call this after SlackBot is ready.\n\t */\n\tstart(): void {\n\t\tthis.stopped = false;\n\n\t\t// Ensure events directory exists\n\t\tif (!existsSync(this.eventsDir)) {\n\t\t\tmkdirSync(this.eventsDir, { recursive: true });\n\t\t}\n\n\t\tlog.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n\t\t// Scan existing files\n\t\tthis.scanExisting();\n\n\t\tthis.startFsWatcher();\n\n\t\tlog.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n\t}\n\n\t/**\n\t * Stop watching and cancel all scheduled events.\n\t */\n\tstop(): void {\n\t\tthis.stopped = true;\n\n\t\t// Stop fs watcher\n\t\tcloseWatcher(this.watcher);\n\t\tthis.watcher = null;\n\t\tif (this.watcherRetryTimer) {\n\t\t\tclearTimeout(this.watcherRetryTimer);\n\t\t\tthis.watcherRetryTimer = null;\n\t\t}\n\n\t\t// Cancel all debounce timers\n\t\tfor (const timer of this.debounceTimers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.debounceTimers.clear();\n\n\t\t// Cancel all scheduled timers\n\t\tfor (const timer of this.timers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.timers.clear();\n\n\t\t// Cancel all cron jobs\n\t\tfor (const cron of this.crons.values()) {\n\t\t\tcron.stop();\n\t\t}\n\t\tthis.crons.clear();\n\n\t\tthis.knownFiles.clear();\n\t\tlog.logInfo(\"Events watcher stopped\");\n\t}\n\n\tprivate startFsWatcher(): void {\n\t\tthis.watcher = watchWithErrorHandler(\n\t\t\tthis.eventsDir,\n\t\t\t(_eventType, filename) => {\n\t\t\t\tif (!filename || !filename.endsWith(\".json\")) return;\n\t\t\t\tthis.debounce(filename, () => this.handleFileChange(filename));\n\t\t\t},\n\t\t\t() => this.handleFsWatcherError(),\n\t\t);\n\t}\n\n\tprivate handleFsWatcherError(): void {\n\t\tcloseWatcher(this.watcher);\n\t\tthis.watcher = null;\n\t\tthis.scheduleFsWatcherRetry();\n\t}\n\n\tprivate scheduleFsWatcherRetry(): void {\n\t\tif (this.stopped || this.watcherRetryTimer) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.watcherRetryTimer = setTimeout(() => {\n\t\t\tthis.watcherRetryTimer = null;\n\t\t\tif (this.stopped) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.startFsWatcher();\n\t\t\tif (this.watcher) {\n\t\t\t\tthis.rescanExisting();\n\t\t\t}\n\t\t}, FS_WATCH_RETRY_DELAY_MS);\n\t}\n\n\tprivate debounce(filename: string, fn: () => void): void {\n\t\tconst existing = this.debounceTimers.get(filename);\n\t\tif (existing) {\n\t\t\tclearTimeout(existing);\n\t\t}\n\t\tthis.debounceTimers.set(\n\t\t\tfilename,\n\t\t\tsetTimeout(() => {\n\t\t\t\tthis.debounceTimers.delete(filename);\n\t\t\t\tfn();\n\t\t\t}, DEBOUNCE_MS),\n\t\t);\n\t}\n\n\tprivate scanExisting(): void {\n\t\tlet files: string[];\n\t\ttry {\n\t\t\tfiles = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Failed to read events directory\", String(err));\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const filename of files) {\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate rescanExisting(): void {\n\t\tlet files: string[];\n\t\ttry {\n\t\t\tfiles = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Failed to read events directory\", String(err));\n\t\t\treturn;\n\t\t}\n\n\t\tconst currentFiles = new Set(files);\n\t\tfor (const filename of files) {\n\t\t\tthis.handleFileChange(filename);\n\t\t}\n\t\tfor (const filename of Array.from(this.knownFiles)) {\n\t\t\tif (!currentFiles.has(filename)) {\n\t\t\t\tthis.handleDelete(filename);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate handleFileChange(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\tif (!existsSync(filePath)) {\n\t\t\t// File was deleted\n\t\t\tthis.handleDelete(filename);\n\t\t} else if (this.knownFiles.has(filename)) {\n\t\t\t// File was modified - cancel existing and re-schedule\n\t\t\tthis.cancelScheduled(filename);\n\t\t\tthis.handleFile(filename);\n\t\t} else {\n\t\t\t// New file\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate handleDelete(filename: string): void {\n\t\tif (!this.knownFiles.has(filename)) return;\n\n\t\tlog.logInfo(`Event file deleted: ${filename}`);\n\t\tthis.cancelScheduled(filename);\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate cancelScheduled(filename: string): void {\n\t\tconst timer = this.timers.get(filename);\n\t\tif (timer) {\n\t\t\tclearTimeout(timer);\n\t\t\tthis.timers.delete(filename);\n\t\t}\n\n\t\tconst cron = this.crons.get(filename);\n\t\tif (cron) {\n\t\t\tcron.stop();\n\t\t\tthis.crons.delete(filename);\n\t\t}\n\t}\n\n\tprivate async handleFile(filename: string): Promise<void> {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Parse with retries\n\t\tlet event: MomEvent | null = null;\n\t\tlet lastError: Error | null = null;\n\n\t\tfor (let i = 0; i < MAX_RETRIES; i++) {\n\t\t\ttry {\n\t\t\t\tconst content = await readFile(filePath, \"utf-8\");\n\t\t\t\tevent = this.parseEvent(content, filename);\n\t\t\t\tbreak;\n\t\t\t} catch (err) {\n\t\t\t\tlastError = err instanceof Error ? err : new Error(String(err));\n\t\t\t\tif (i < MAX_RETRIES - 1) {\n\t\t\t\t\tawait this.sleep(RETRY_BASE_MS * 2 ** i);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (!event) {\n\t\t\tlog.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.knownFiles.add(filename);\n\n\t\t// Schedule based on type\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tthis.handleImmediate(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tthis.handleOneShot(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tthis.handlePeriodic(filename, event);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate parseEvent(content: string, filename: string): MomEvent | null {\n\t\tconst data = JSON.parse(content);\n\n\t\tif (!data.type || !data.channelId || !data.text) {\n\t\t\tthrow new Error(`Missing required fields (type, channelId, text) in ${filename}`);\n\t\t}\n\n\t\tswitch (data.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\treturn { type: \"immediate\", channelId: data.channelId, text: data.text };\n\n\t\t\tcase \"one-shot\":\n\t\t\t\tif (!data.at) {\n\t\t\t\t\tthrow new Error(`Missing 'at' field for one-shot event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn { type: \"one-shot\", channelId: data.channelId, text: data.text, at: data.at };\n\n\t\t\tcase \"periodic\":\n\t\t\t\tif (!data.schedule) {\n\t\t\t\t\tthrow new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\tif (!data.timezone) {\n\t\t\t\t\tthrow new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"periodic\",\n\t\t\t\t\tchannelId: data.channelId,\n\t\t\t\t\ttext: data.text,\n\t\t\t\t\tschedule: data.schedule,\n\t\t\t\t\ttimezone: data.timezone,\n\t\t\t\t};\n\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown event type '${data.type}' in ${filename}`);\n\t\t}\n\t}\n\n\tprivate handleImmediate(filename: string, event: ImmediateEvent): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Check if stale (created before harness started)\n\t\ttry {\n\t\t\tconst stat = statSync(filePath);\n\t\t\tif (stat.mtimeMs < this.startTime) {\n\t\t\t\tlog.logInfo(`Stale immediate event, deleting: ${filename}`);\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch {\n\t\t\t// File may have been deleted\n\t\t\treturn;\n\t\t}\n\n\t\tlog.logInfo(`Executing immediate event: ${filename}`);\n\t\tthis.execute(filename, event);\n\t}\n\n\tprivate handleOneShot(filename: string, event: OneShotEvent): void {\n\t\tconst atTime = new Date(event.at).getTime();\n\t\tconst now = Date.now();\n\n\t\tif (atTime <= now) {\n\t\t\t// Past - delete without executing\n\t\t\tlog.logInfo(`One-shot event in the past, deleting: ${filename}`);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tconst delay = atTime - now;\n\t\tlog.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);\n\n\t\tconst timer = setTimeout(() => {\n\t\t\tthis.timers.delete(filename);\n\t\t\tlog.logInfo(`Executing one-shot event: ${filename}`);\n\t\t\tthis.execute(filename, event);\n\t\t}, delay);\n\n\t\tthis.timers.set(filename, timer);\n\t}\n\n\tprivate handlePeriodic(filename: string, event: PeriodicEvent): void {\n\t\ttry {\n\t\t\tconst cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n\t\t\t\tlog.logInfo(`Executing periodic event: ${filename}`);\n\t\t\t\tthis.execute(filename, event, false); // Don't delete periodic events\n\t\t\t});\n\n\t\t\tthis.crons.set(filename, cron);\n\n\t\t\tconst next = cron.nextRun();\n\t\t\tlog.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`);\n\t\t} catch (err) {\n\t\t\tlog.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n\t\t\tthis.deleteFile(filename);\n\t\t}\n\t}\n\n\tprivate execute(filename: string, event: MomEvent, deleteAfter: boolean = true): void {\n\t\t// Format the message\n\t\tlet scheduleInfo: string;\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tscheduleInfo = \"immediate\";\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tscheduleInfo = event.at;\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tscheduleInfo = event.schedule;\n\t\t\t\tbreak;\n\t\t}\n\n\t\tconst message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;\n\n\t\t// Create synthetic SlackEvent\n\t\tconst syntheticEvent: SlackEvent = {\n\t\t\ttype: \"mention\",\n\t\t\tchannel: event.channelId,\n\t\t\tuser: \"EVENT\",\n\t\t\ttext: message,\n\t\t\tts: Date.now().toString(),\n\t\t};\n\n\t\t// Enqueue for processing\n\t\tconst enqueued = this.slack.enqueueEvent(syntheticEvent);\n\n\t\tif (enqueued && deleteAfter) {\n\t\t\t// Delete file after successful enqueue (immediate and one-shot)\n\t\t\tthis.deleteFile(filename);\n\t\t} else if (!enqueued) {\n\t\t\tlog.logWarning(`Event queue full, discarded: ${filename}`);\n\t\t\t// Still delete immediate/one-shot even if discarded\n\t\t\tif (deleteAfter) {\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate deleteFile(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\t\ttry {\n\t\t\tunlinkSync(filePath);\n\t\t} catch (err) {\n\t\t\t// ENOENT is fine (file already deleted), other errors are warnings\n\t\t\tif (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n\t\t\t\tlog.logWarning(`Failed to delete event file: ${filename}`, String(err));\n\t\t\t}\n\t\t}\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate sleep(ms: number): Promise<void> {\n\t\treturn new Promise((resolve) => setTimeout(resolve, ms));\n\t}\n}\n\n/**\n * Create and start an events watcher.\n */\nexport function createEventsWatcher(workspaceDir: string, slack: SlackBot): EventsWatcher {\n\tconst eventsDir = join(workspaceDir, \"events\");\n\treturn new EventsWatcher(eventsDir, slack);\n}\n"]}
package/dist/events.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Cron } from "croner";
2
- import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "fs";
2
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
3
3
  import { readFile } from "fs/promises";
4
4
  import { join } from "path";
5
+ import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "./fs-watch.js";
5
6
  import * as log from "./log.js";
6
7
  // ============================================================================
7
8
  // EventsWatcher
@@ -17,7 +18,9 @@ export class EventsWatcher {
17
18
  debounceTimers = new Map();
18
19
  startTime;
19
20
  watcher = null;
21
+ watcherRetryTimer = null;
20
22
  knownFiles = new Set();
23
+ stopped = true;
21
24
  constructor(eventsDir, slack) {
22
25
  this.eventsDir = eventsDir;
23
26
  this.slack = slack;
@@ -27,6 +30,7 @@ export class EventsWatcher {
27
30
  * Start watching for events. Call this after SlackBot is ready.
28
31
  */
29
32
  start() {
33
+ this.stopped = false;
30
34
  // Ensure events directory exists
31
35
  if (!existsSync(this.eventsDir)) {
32
36
  mkdirSync(this.eventsDir, { recursive: true });
@@ -34,22 +38,20 @@ export class EventsWatcher {
34
38
  log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);
35
39
  // Scan existing files
36
40
  this.scanExisting();
37
- // Watch for changes
38
- this.watcher = watch(this.eventsDir, (_eventType, filename) => {
39
- if (!filename || !filename.endsWith(".json"))
40
- return;
41
- this.debounce(filename, () => this.handleFileChange(filename));
42
- });
41
+ this.startFsWatcher();
43
42
  log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);
44
43
  }
45
44
  /**
46
45
  * Stop watching and cancel all scheduled events.
47
46
  */
48
47
  stop() {
48
+ this.stopped = true;
49
49
  // Stop fs watcher
50
- if (this.watcher) {
51
- this.watcher.close();
52
- this.watcher = null;
50
+ closeWatcher(this.watcher);
51
+ this.watcher = null;
52
+ if (this.watcherRetryTimer) {
53
+ clearTimeout(this.watcherRetryTimer);
54
+ this.watcherRetryTimer = null;
53
55
  }
54
56
  // Cancel all debounce timers
55
57
  for (const timer of this.debounceTimers.values()) {
@@ -69,6 +71,33 @@ export class EventsWatcher {
69
71
  this.knownFiles.clear();
70
72
  log.logInfo("Events watcher stopped");
71
73
  }
74
+ startFsWatcher() {
75
+ this.watcher = watchWithErrorHandler(this.eventsDir, (_eventType, filename) => {
76
+ if (!filename || !filename.endsWith(".json"))
77
+ return;
78
+ this.debounce(filename, () => this.handleFileChange(filename));
79
+ }, () => this.handleFsWatcherError());
80
+ }
81
+ handleFsWatcherError() {
82
+ closeWatcher(this.watcher);
83
+ this.watcher = null;
84
+ this.scheduleFsWatcherRetry();
85
+ }
86
+ scheduleFsWatcherRetry() {
87
+ if (this.stopped || this.watcherRetryTimer) {
88
+ return;
89
+ }
90
+ this.watcherRetryTimer = setTimeout(() => {
91
+ this.watcherRetryTimer = null;
92
+ if (this.stopped) {
93
+ return;
94
+ }
95
+ this.startFsWatcher();
96
+ if (this.watcher) {
97
+ this.rescanExisting();
98
+ }
99
+ }, FS_WATCH_RETRY_DELAY_MS);
100
+ }
72
101
  debounce(filename, fn) {
73
102
  const existing = this.debounceTimers.get(filename);
74
103
  if (existing) {
@@ -92,6 +121,25 @@ export class EventsWatcher {
92
121
  this.handleFile(filename);
93
122
  }
94
123
  }
124
+ rescanExisting() {
125
+ let files;
126
+ try {
127
+ files = readdirSync(this.eventsDir).filter((f) => f.endsWith(".json"));
128
+ }
129
+ catch (err) {
130
+ log.logWarning("Failed to read events directory", String(err));
131
+ return;
132
+ }
133
+ const currentFiles = new Set(files);
134
+ for (const filename of files) {
135
+ this.handleFileChange(filename);
136
+ }
137
+ for (const filename of Array.from(this.knownFiles)) {
138
+ if (!currentFiles.has(filename)) {
139
+ this.handleDelete(filename);
140
+ }
141
+ }
142
+ }
95
143
  handleFileChange(filename) {
96
144
  const filePath = join(this.eventsDir, filename);
97
145
  if (!existsSync(filePath)) {
@@ -1 +1 @@
1
- {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"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","sourcesContent":["import { Cron } from \"croner\";\nimport { existsSync, type FSWatcher, mkdirSync, readdirSync, statSync, unlinkSync, watch } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\nimport type { SlackBot, SlackEvent } from \"./slack.js\";\n\n// ============================================================================\n// Event Types\n// ============================================================================\n\nexport interface ImmediateEvent {\n\ttype: \"immediate\";\n\tchannelId: string;\n\ttext: string;\n}\n\nexport interface OneShotEvent {\n\ttype: \"one-shot\";\n\tchannelId: string;\n\ttext: string;\n\tat: string; // ISO 8601 with timezone offset\n}\n\nexport interface PeriodicEvent {\n\ttype: \"periodic\";\n\tchannelId: string;\n\ttext: string;\n\tschedule: string; // cron syntax\n\ttimezone: string; // IANA timezone\n}\n\nexport type MomEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\n// ============================================================================\n// EventsWatcher\n// ============================================================================\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n\tprivate timers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate crons: Map<string, Cron> = new Map();\n\tprivate debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate startTime: number;\n\tprivate watcher: FSWatcher | null = null;\n\tprivate knownFiles: Set<string> = new Set();\n\n\tconstructor(\n\t\tprivate eventsDir: string,\n\t\tprivate slack: SlackBot,\n\t) {\n\t\tthis.startTime = Date.now();\n\t}\n\n\t/**\n\t * Start watching for events. Call this after SlackBot is ready.\n\t */\n\tstart(): void {\n\t\t// Ensure events directory exists\n\t\tif (!existsSync(this.eventsDir)) {\n\t\t\tmkdirSync(this.eventsDir, { recursive: true });\n\t\t}\n\n\t\tlog.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n\t\t// Scan existing files\n\t\tthis.scanExisting();\n\n\t\t// Watch for changes\n\t\tthis.watcher = watch(this.eventsDir, (_eventType, filename) => {\n\t\t\tif (!filename || !filename.endsWith(\".json\")) return;\n\t\t\tthis.debounce(filename, () => this.handleFileChange(filename));\n\t\t});\n\n\t\tlog.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n\t}\n\n\t/**\n\t * Stop watching and cancel all scheduled events.\n\t */\n\tstop(): void {\n\t\t// Stop fs watcher\n\t\tif (this.watcher) {\n\t\t\tthis.watcher.close();\n\t\t\tthis.watcher = null;\n\t\t}\n\n\t\t// Cancel all debounce timers\n\t\tfor (const timer of this.debounceTimers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.debounceTimers.clear();\n\n\t\t// Cancel all scheduled timers\n\t\tfor (const timer of this.timers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.timers.clear();\n\n\t\t// Cancel all cron jobs\n\t\tfor (const cron of this.crons.values()) {\n\t\t\tcron.stop();\n\t\t}\n\t\tthis.crons.clear();\n\n\t\tthis.knownFiles.clear();\n\t\tlog.logInfo(\"Events watcher stopped\");\n\t}\n\n\tprivate debounce(filename: string, fn: () => void): void {\n\t\tconst existing = this.debounceTimers.get(filename);\n\t\tif (existing) {\n\t\t\tclearTimeout(existing);\n\t\t}\n\t\tthis.debounceTimers.set(\n\t\t\tfilename,\n\t\t\tsetTimeout(() => {\n\t\t\t\tthis.debounceTimers.delete(filename);\n\t\t\t\tfn();\n\t\t\t}, DEBOUNCE_MS),\n\t\t);\n\t}\n\n\tprivate scanExisting(): void {\n\t\tlet files: string[];\n\t\ttry {\n\t\t\tfiles = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Failed to read events directory\", String(err));\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const filename of files) {\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate handleFileChange(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\tif (!existsSync(filePath)) {\n\t\t\t// File was deleted\n\t\t\tthis.handleDelete(filename);\n\t\t} else if (this.knownFiles.has(filename)) {\n\t\t\t// File was modified - cancel existing and re-schedule\n\t\t\tthis.cancelScheduled(filename);\n\t\t\tthis.handleFile(filename);\n\t\t} else {\n\t\t\t// New file\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate handleDelete(filename: string): void {\n\t\tif (!this.knownFiles.has(filename)) return;\n\n\t\tlog.logInfo(`Event file deleted: ${filename}`);\n\t\tthis.cancelScheduled(filename);\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate cancelScheduled(filename: string): void {\n\t\tconst timer = this.timers.get(filename);\n\t\tif (timer) {\n\t\t\tclearTimeout(timer);\n\t\t\tthis.timers.delete(filename);\n\t\t}\n\n\t\tconst cron = this.crons.get(filename);\n\t\tif (cron) {\n\t\t\tcron.stop();\n\t\t\tthis.crons.delete(filename);\n\t\t}\n\t}\n\n\tprivate async handleFile(filename: string): Promise<void> {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Parse with retries\n\t\tlet event: MomEvent | null = null;\n\t\tlet lastError: Error | null = null;\n\n\t\tfor (let i = 0; i < MAX_RETRIES; i++) {\n\t\t\ttry {\n\t\t\t\tconst content = await readFile(filePath, \"utf-8\");\n\t\t\t\tevent = this.parseEvent(content, filename);\n\t\t\t\tbreak;\n\t\t\t} catch (err) {\n\t\t\t\tlastError = err instanceof Error ? err : new Error(String(err));\n\t\t\t\tif (i < MAX_RETRIES - 1) {\n\t\t\t\t\tawait this.sleep(RETRY_BASE_MS * 2 ** i);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (!event) {\n\t\t\tlog.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.knownFiles.add(filename);\n\n\t\t// Schedule based on type\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tthis.handleImmediate(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tthis.handleOneShot(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tthis.handlePeriodic(filename, event);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate parseEvent(content: string, filename: string): MomEvent | null {\n\t\tconst data = JSON.parse(content);\n\n\t\tif (!data.type || !data.channelId || !data.text) {\n\t\t\tthrow new Error(`Missing required fields (type, channelId, text) in ${filename}`);\n\t\t}\n\n\t\tswitch (data.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\treturn { type: \"immediate\", channelId: data.channelId, text: data.text };\n\n\t\t\tcase \"one-shot\":\n\t\t\t\tif (!data.at) {\n\t\t\t\t\tthrow new Error(`Missing 'at' field for one-shot event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn { type: \"one-shot\", channelId: data.channelId, text: data.text, at: data.at };\n\n\t\t\tcase \"periodic\":\n\t\t\t\tif (!data.schedule) {\n\t\t\t\t\tthrow new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\tif (!data.timezone) {\n\t\t\t\t\tthrow new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"periodic\",\n\t\t\t\t\tchannelId: data.channelId,\n\t\t\t\t\ttext: data.text,\n\t\t\t\t\tschedule: data.schedule,\n\t\t\t\t\ttimezone: data.timezone,\n\t\t\t\t};\n\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown event type '${data.type}' in ${filename}`);\n\t\t}\n\t}\n\n\tprivate handleImmediate(filename: string, event: ImmediateEvent): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Check if stale (created before harness started)\n\t\ttry {\n\t\t\tconst stat = statSync(filePath);\n\t\t\tif (stat.mtimeMs < this.startTime) {\n\t\t\t\tlog.logInfo(`Stale immediate event, deleting: ${filename}`);\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch {\n\t\t\t// File may have been deleted\n\t\t\treturn;\n\t\t}\n\n\t\tlog.logInfo(`Executing immediate event: ${filename}`);\n\t\tthis.execute(filename, event);\n\t}\n\n\tprivate handleOneShot(filename: string, event: OneShotEvent): void {\n\t\tconst atTime = new Date(event.at).getTime();\n\t\tconst now = Date.now();\n\n\t\tif (atTime <= now) {\n\t\t\t// Past - delete without executing\n\t\t\tlog.logInfo(`One-shot event in the past, deleting: ${filename}`);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tconst delay = atTime - now;\n\t\tlog.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);\n\n\t\tconst timer = setTimeout(() => {\n\t\t\tthis.timers.delete(filename);\n\t\t\tlog.logInfo(`Executing one-shot event: ${filename}`);\n\t\t\tthis.execute(filename, event);\n\t\t}, delay);\n\n\t\tthis.timers.set(filename, timer);\n\t}\n\n\tprivate handlePeriodic(filename: string, event: PeriodicEvent): void {\n\t\ttry {\n\t\t\tconst cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n\t\t\t\tlog.logInfo(`Executing periodic event: ${filename}`);\n\t\t\t\tthis.execute(filename, event, false); // Don't delete periodic events\n\t\t\t});\n\n\t\t\tthis.crons.set(filename, cron);\n\n\t\t\tconst next = cron.nextRun();\n\t\t\tlog.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`);\n\t\t} catch (err) {\n\t\t\tlog.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n\t\t\tthis.deleteFile(filename);\n\t\t}\n\t}\n\n\tprivate execute(filename: string, event: MomEvent, deleteAfter: boolean = true): void {\n\t\t// Format the message\n\t\tlet scheduleInfo: string;\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tscheduleInfo = \"immediate\";\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tscheduleInfo = event.at;\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tscheduleInfo = event.schedule;\n\t\t\t\tbreak;\n\t\t}\n\n\t\tconst message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;\n\n\t\t// Create synthetic SlackEvent\n\t\tconst syntheticEvent: SlackEvent = {\n\t\t\ttype: \"mention\",\n\t\t\tchannel: event.channelId,\n\t\t\tuser: \"EVENT\",\n\t\t\ttext: message,\n\t\t\tts: Date.now().toString(),\n\t\t};\n\n\t\t// Enqueue for processing\n\t\tconst enqueued = this.slack.enqueueEvent(syntheticEvent);\n\n\t\tif (enqueued && deleteAfter) {\n\t\t\t// Delete file after successful enqueue (immediate and one-shot)\n\t\t\tthis.deleteFile(filename);\n\t\t} else if (!enqueued) {\n\t\t\tlog.logWarning(`Event queue full, discarded: ${filename}`);\n\t\t\t// Still delete immediate/one-shot even if discarded\n\t\t\tif (deleteAfter) {\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate deleteFile(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\t\ttry {\n\t\t\tunlinkSync(filePath);\n\t\t} catch (err) {\n\t\t\t// ENOENT is fine (file already deleted), other errors are warnings\n\t\t\tif (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n\t\t\t\tlog.logWarning(`Failed to delete event file: ${filename}`, String(err));\n\t\t\t}\n\t\t}\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate sleep(ms: number): Promise<void> {\n\t\treturn new Promise((resolve) => setTimeout(resolve, ms));\n\t}\n}\n\n/**\n * Create and start an events watcher.\n */\nexport function createEventsWatcher(workspaceDir: string, slack: SlackBot): EventsWatcher {\n\tconst eventsDir = join(workspaceDir, \"events\");\n\treturn new EventsWatcher(eventsDir, slack);\n}\n"]}
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"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","sourcesContent":["import { Cron } from \"croner\";\nimport { existsSync, type FSWatcher, mkdirSync, readdirSync, statSync, unlinkSync } from \"fs\";\nimport { readFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from \"./fs-watch.js\";\nimport * as log from \"./log.js\";\nimport type { SlackBot, SlackEvent } from \"./slack.js\";\n\n// ============================================================================\n// Event Types\n// ============================================================================\n\nexport interface ImmediateEvent {\n\ttype: \"immediate\";\n\tchannelId: string;\n\ttext: string;\n}\n\nexport interface OneShotEvent {\n\ttype: \"one-shot\";\n\tchannelId: string;\n\ttext: string;\n\tat: string; // ISO 8601 with timezone offset\n}\n\nexport interface PeriodicEvent {\n\ttype: \"periodic\";\n\tchannelId: string;\n\ttext: string;\n\tschedule: string; // cron syntax\n\ttimezone: string; // IANA timezone\n}\n\nexport type MomEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;\n\n// ============================================================================\n// EventsWatcher\n// ============================================================================\n\nconst DEBOUNCE_MS = 100;\nconst MAX_RETRIES = 3;\nconst RETRY_BASE_MS = 100;\n\nexport class EventsWatcher {\n\tprivate timers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate crons: Map<string, Cron> = new Map();\n\tprivate debounceTimers: Map<string, NodeJS.Timeout> = new Map();\n\tprivate startTime: number;\n\tprivate watcher: FSWatcher | null = null;\n\tprivate watcherRetryTimer: NodeJS.Timeout | null = null;\n\tprivate knownFiles: Set<string> = new Set();\n\tprivate stopped = true;\n\n\tconstructor(\n\t\tprivate eventsDir: string,\n\t\tprivate slack: SlackBot,\n\t) {\n\t\tthis.startTime = Date.now();\n\t}\n\n\t/**\n\t * Start watching for events. Call this after SlackBot is ready.\n\t */\n\tstart(): void {\n\t\tthis.stopped = false;\n\n\t\t// Ensure events directory exists\n\t\tif (!existsSync(this.eventsDir)) {\n\t\t\tmkdirSync(this.eventsDir, { recursive: true });\n\t\t}\n\n\t\tlog.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);\n\n\t\t// Scan existing files\n\t\tthis.scanExisting();\n\n\t\tthis.startFsWatcher();\n\n\t\tlog.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);\n\t}\n\n\t/**\n\t * Stop watching and cancel all scheduled events.\n\t */\n\tstop(): void {\n\t\tthis.stopped = true;\n\n\t\t// Stop fs watcher\n\t\tcloseWatcher(this.watcher);\n\t\tthis.watcher = null;\n\t\tif (this.watcherRetryTimer) {\n\t\t\tclearTimeout(this.watcherRetryTimer);\n\t\t\tthis.watcherRetryTimer = null;\n\t\t}\n\n\t\t// Cancel all debounce timers\n\t\tfor (const timer of this.debounceTimers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.debounceTimers.clear();\n\n\t\t// Cancel all scheduled timers\n\t\tfor (const timer of this.timers.values()) {\n\t\t\tclearTimeout(timer);\n\t\t}\n\t\tthis.timers.clear();\n\n\t\t// Cancel all cron jobs\n\t\tfor (const cron of this.crons.values()) {\n\t\t\tcron.stop();\n\t\t}\n\t\tthis.crons.clear();\n\n\t\tthis.knownFiles.clear();\n\t\tlog.logInfo(\"Events watcher stopped\");\n\t}\n\n\tprivate startFsWatcher(): void {\n\t\tthis.watcher = watchWithErrorHandler(\n\t\t\tthis.eventsDir,\n\t\t\t(_eventType, filename) => {\n\t\t\t\tif (!filename || !filename.endsWith(\".json\")) return;\n\t\t\t\tthis.debounce(filename, () => this.handleFileChange(filename));\n\t\t\t},\n\t\t\t() => this.handleFsWatcherError(),\n\t\t);\n\t}\n\n\tprivate handleFsWatcherError(): void {\n\t\tcloseWatcher(this.watcher);\n\t\tthis.watcher = null;\n\t\tthis.scheduleFsWatcherRetry();\n\t}\n\n\tprivate scheduleFsWatcherRetry(): void {\n\t\tif (this.stopped || this.watcherRetryTimer) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.watcherRetryTimer = setTimeout(() => {\n\t\t\tthis.watcherRetryTimer = null;\n\t\t\tif (this.stopped) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.startFsWatcher();\n\t\t\tif (this.watcher) {\n\t\t\t\tthis.rescanExisting();\n\t\t\t}\n\t\t}, FS_WATCH_RETRY_DELAY_MS);\n\t}\n\n\tprivate debounce(filename: string, fn: () => void): void {\n\t\tconst existing = this.debounceTimers.get(filename);\n\t\tif (existing) {\n\t\t\tclearTimeout(existing);\n\t\t}\n\t\tthis.debounceTimers.set(\n\t\t\tfilename,\n\t\t\tsetTimeout(() => {\n\t\t\t\tthis.debounceTimers.delete(filename);\n\t\t\t\tfn();\n\t\t\t}, DEBOUNCE_MS),\n\t\t);\n\t}\n\n\tprivate scanExisting(): void {\n\t\tlet files: string[];\n\t\ttry {\n\t\t\tfiles = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Failed to read events directory\", String(err));\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const filename of files) {\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate rescanExisting(): void {\n\t\tlet files: string[];\n\t\ttry {\n\t\t\tfiles = readdirSync(this.eventsDir).filter((f) => f.endsWith(\".json\"));\n\t\t} catch (err) {\n\t\t\tlog.logWarning(\"Failed to read events directory\", String(err));\n\t\t\treturn;\n\t\t}\n\n\t\tconst currentFiles = new Set(files);\n\t\tfor (const filename of files) {\n\t\t\tthis.handleFileChange(filename);\n\t\t}\n\t\tfor (const filename of Array.from(this.knownFiles)) {\n\t\t\tif (!currentFiles.has(filename)) {\n\t\t\t\tthis.handleDelete(filename);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate handleFileChange(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\tif (!existsSync(filePath)) {\n\t\t\t// File was deleted\n\t\t\tthis.handleDelete(filename);\n\t\t} else if (this.knownFiles.has(filename)) {\n\t\t\t// File was modified - cancel existing and re-schedule\n\t\t\tthis.cancelScheduled(filename);\n\t\t\tthis.handleFile(filename);\n\t\t} else {\n\t\t\t// New file\n\t\t\tthis.handleFile(filename);\n\t\t}\n\t}\n\n\tprivate handleDelete(filename: string): void {\n\t\tif (!this.knownFiles.has(filename)) return;\n\n\t\tlog.logInfo(`Event file deleted: ${filename}`);\n\t\tthis.cancelScheduled(filename);\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate cancelScheduled(filename: string): void {\n\t\tconst timer = this.timers.get(filename);\n\t\tif (timer) {\n\t\t\tclearTimeout(timer);\n\t\t\tthis.timers.delete(filename);\n\t\t}\n\n\t\tconst cron = this.crons.get(filename);\n\t\tif (cron) {\n\t\t\tcron.stop();\n\t\t\tthis.crons.delete(filename);\n\t\t}\n\t}\n\n\tprivate async handleFile(filename: string): Promise<void> {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Parse with retries\n\t\tlet event: MomEvent | null = null;\n\t\tlet lastError: Error | null = null;\n\n\t\tfor (let i = 0; i < MAX_RETRIES; i++) {\n\t\t\ttry {\n\t\t\t\tconst content = await readFile(filePath, \"utf-8\");\n\t\t\t\tevent = this.parseEvent(content, filename);\n\t\t\t\tbreak;\n\t\t\t} catch (err) {\n\t\t\t\tlastError = err instanceof Error ? err : new Error(String(err));\n\t\t\t\tif (i < MAX_RETRIES - 1) {\n\t\t\t\t\tawait this.sleep(RETRY_BASE_MS * 2 ** i);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (!event) {\n\t\t\tlog.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.knownFiles.add(filename);\n\n\t\t// Schedule based on type\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tthis.handleImmediate(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tthis.handleOneShot(filename, event);\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tthis.handlePeriodic(filename, event);\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate parseEvent(content: string, filename: string): MomEvent | null {\n\t\tconst data = JSON.parse(content);\n\n\t\tif (!data.type || !data.channelId || !data.text) {\n\t\t\tthrow new Error(`Missing required fields (type, channelId, text) in ${filename}`);\n\t\t}\n\n\t\tswitch (data.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\treturn { type: \"immediate\", channelId: data.channelId, text: data.text };\n\n\t\t\tcase \"one-shot\":\n\t\t\t\tif (!data.at) {\n\t\t\t\t\tthrow new Error(`Missing 'at' field for one-shot event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn { type: \"one-shot\", channelId: data.channelId, text: data.text, at: data.at };\n\n\t\t\tcase \"periodic\":\n\t\t\t\tif (!data.schedule) {\n\t\t\t\t\tthrow new Error(`Missing 'schedule' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\tif (!data.timezone) {\n\t\t\t\t\tthrow new Error(`Missing 'timezone' field for periodic event in ${filename}`);\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"periodic\",\n\t\t\t\t\tchannelId: data.channelId,\n\t\t\t\t\ttext: data.text,\n\t\t\t\t\tschedule: data.schedule,\n\t\t\t\t\ttimezone: data.timezone,\n\t\t\t\t};\n\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown event type '${data.type}' in ${filename}`);\n\t\t}\n\t}\n\n\tprivate handleImmediate(filename: string, event: ImmediateEvent): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\n\t\t// Check if stale (created before harness started)\n\t\ttry {\n\t\t\tconst stat = statSync(filePath);\n\t\t\tif (stat.mtimeMs < this.startTime) {\n\t\t\t\tlog.logInfo(`Stale immediate event, deleting: ${filename}`);\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch {\n\t\t\t// File may have been deleted\n\t\t\treturn;\n\t\t}\n\n\t\tlog.logInfo(`Executing immediate event: ${filename}`);\n\t\tthis.execute(filename, event);\n\t}\n\n\tprivate handleOneShot(filename: string, event: OneShotEvent): void {\n\t\tconst atTime = new Date(event.at).getTime();\n\t\tconst now = Date.now();\n\n\t\tif (atTime <= now) {\n\t\t\t// Past - delete without executing\n\t\t\tlog.logInfo(`One-shot event in the past, deleting: ${filename}`);\n\t\t\tthis.deleteFile(filename);\n\t\t\treturn;\n\t\t}\n\n\t\tconst delay = atTime - now;\n\t\tlog.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);\n\n\t\tconst timer = setTimeout(() => {\n\t\t\tthis.timers.delete(filename);\n\t\t\tlog.logInfo(`Executing one-shot event: ${filename}`);\n\t\t\tthis.execute(filename, event);\n\t\t}, delay);\n\n\t\tthis.timers.set(filename, timer);\n\t}\n\n\tprivate handlePeriodic(filename: string, event: PeriodicEvent): void {\n\t\ttry {\n\t\t\tconst cron = new Cron(event.schedule, { timezone: event.timezone }, () => {\n\t\t\t\tlog.logInfo(`Executing periodic event: ${filename}`);\n\t\t\t\tthis.execute(filename, event, false); // Don't delete periodic events\n\t\t\t});\n\n\t\t\tthis.crons.set(filename, cron);\n\n\t\t\tconst next = cron.nextRun();\n\t\t\tlog.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? \"unknown\"}`);\n\t\t} catch (err) {\n\t\t\tlog.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));\n\t\t\tthis.deleteFile(filename);\n\t\t}\n\t}\n\n\tprivate execute(filename: string, event: MomEvent, deleteAfter: boolean = true): void {\n\t\t// Format the message\n\t\tlet scheduleInfo: string;\n\t\tswitch (event.type) {\n\t\t\tcase \"immediate\":\n\t\t\t\tscheduleInfo = \"immediate\";\n\t\t\t\tbreak;\n\t\t\tcase \"one-shot\":\n\t\t\t\tscheduleInfo = event.at;\n\t\t\t\tbreak;\n\t\t\tcase \"periodic\":\n\t\t\t\tscheduleInfo = event.schedule;\n\t\t\t\tbreak;\n\t\t}\n\n\t\tconst message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;\n\n\t\t// Create synthetic SlackEvent\n\t\tconst syntheticEvent: SlackEvent = {\n\t\t\ttype: \"mention\",\n\t\t\tchannel: event.channelId,\n\t\t\tuser: \"EVENT\",\n\t\t\ttext: message,\n\t\t\tts: Date.now().toString(),\n\t\t};\n\n\t\t// Enqueue for processing\n\t\tconst enqueued = this.slack.enqueueEvent(syntheticEvent);\n\n\t\tif (enqueued && deleteAfter) {\n\t\t\t// Delete file after successful enqueue (immediate and one-shot)\n\t\t\tthis.deleteFile(filename);\n\t\t} else if (!enqueued) {\n\t\t\tlog.logWarning(`Event queue full, discarded: ${filename}`);\n\t\t\t// Still delete immediate/one-shot even if discarded\n\t\t\tif (deleteAfter) {\n\t\t\t\tthis.deleteFile(filename);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate deleteFile(filename: string): void {\n\t\tconst filePath = join(this.eventsDir, filename);\n\t\ttry {\n\t\t\tunlinkSync(filePath);\n\t\t} catch (err) {\n\t\t\t// ENOENT is fine (file already deleted), other errors are warnings\n\t\t\tif (err instanceof Error && \"code\" in err && err.code !== \"ENOENT\") {\n\t\t\t\tlog.logWarning(`Failed to delete event file: ${filename}`, String(err));\n\t\t\t}\n\t\t}\n\t\tthis.knownFiles.delete(filename);\n\t}\n\n\tprivate sleep(ms: number): Promise<void> {\n\t\treturn new Promise((resolve) => setTimeout(resolve, ms));\n\t}\n}\n\n/**\n * Create and start an events watcher.\n */\nexport function createEventsWatcher(workspaceDir: string, slack: SlackBot): EventsWatcher {\n\tconst eventsDir = join(workspaceDir, \"events\");\n\treturn new EventsWatcher(eventsDir, slack);\n}\n"]}
@@ -0,0 +1,5 @@
1
+ import { type FSWatcher, type WatchListener } from "node:fs";
2
+ export declare const FS_WATCH_RETRY_DELAY_MS = 5000;
3
+ export declare function closeWatcher(watcher: FSWatcher | null | undefined): void;
4
+ export declare function watchWithErrorHandler(path: string, listener: WatchListener<string>, onError: () => void): FSWatcher | null;
5
+ //# sourceMappingURL=fs-watch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-watch.d.ts","sourceRoot":"","sources":["../src/fs-watch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,aAAa,EAAS,MAAM,SAAS,CAAC;AAEpE,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C,wBAAgB,YAAY,CAAC,OAAO,EAAE,SAAS,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAUxE;AAED,wBAAgB,qBAAqB,CACpC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,EAC/B,OAAO,EAAE,MAAM,IAAI,GACjB,SAAS,GAAG,IAAI,CASlB","sourcesContent":["import { type FSWatcher, type WatchListener, watch } from \"node:fs\";\n\nexport const FS_WATCH_RETRY_DELAY_MS = 5000;\n\nexport function closeWatcher(watcher: FSWatcher | null | undefined): void {\n\tif (!watcher) {\n\t\treturn;\n\t}\n\n\ttry {\n\t\twatcher.close();\n\t} catch {\n\t\t// Ignore watcher close errors\n\t}\n}\n\nexport function watchWithErrorHandler(\n\tpath: string,\n\tlistener: WatchListener<string>,\n\tonError: () => void,\n): FSWatcher | null {\n\ttry {\n\t\tconst watcher = watch(path, listener);\n\t\twatcher.on(\"error\", onError);\n\t\treturn watcher;\n\t} catch {\n\t\tonError();\n\t\treturn null;\n\t}\n}\n"]}
@@ -0,0 +1,25 @@
1
+ import { watch } from "node:fs";
2
+ export const FS_WATCH_RETRY_DELAY_MS = 5000;
3
+ export function closeWatcher(watcher) {
4
+ if (!watcher) {
5
+ return;
6
+ }
7
+ try {
8
+ watcher.close();
9
+ }
10
+ catch {
11
+ // Ignore watcher close errors
12
+ }
13
+ }
14
+ export function watchWithErrorHandler(path, listener, onError) {
15
+ try {
16
+ const watcher = watch(path, listener);
17
+ watcher.on("error", onError);
18
+ return watcher;
19
+ }
20
+ catch {
21
+ onError();
22
+ return null;
23
+ }
24
+ }
25
+ //# sourceMappingURL=fs-watch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-watch.js","sourceRoot":"","sources":["../src/fs-watch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,EAAE,MAAM,SAAS,CAAC;AAEpE,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,CAAC;AAE5C,MAAM,UAAU,YAAY,CAAC,OAAqC,EAAQ;IACzE,IAAI,CAAC,OAAO,EAAE,CAAC;QACd,OAAO;IACR,CAAC;IAED,IAAI,CAAC;QACJ,OAAO,CAAC,KAAK,EAAE,CAAC;IACjB,CAAC;IAAC,MAAM,CAAC;QACR,8BAA8B;IAC/B,CAAC;AAAA,CACD;AAED,MAAM,UAAU,qBAAqB,CACpC,IAAY,EACZ,QAA+B,EAC/B,OAAmB,EACA;IACnB,IAAI,CAAC;QACJ,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QACtC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC7B,OAAO,OAAO,CAAC;IAChB,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,EAAE,CAAC;QACV,OAAO,IAAI,CAAC;IACb,CAAC;AAAA,CACD","sourcesContent":["import { type FSWatcher, type WatchListener, watch } from \"node:fs\";\n\nexport const FS_WATCH_RETRY_DELAY_MS = 5000;\n\nexport function closeWatcher(watcher: FSWatcher | null | undefined): void {\n\tif (!watcher) {\n\t\treturn;\n\t}\n\n\ttry {\n\t\twatcher.close();\n\t} catch {\n\t\t// Ignore watcher close errors\n\t}\n}\n\nexport function watchWithErrorHandler(\n\tpath: string,\n\tlistener: WatchListener<string>,\n\tonError: () => void,\n): FSWatcher | null {\n\ttry {\n\t\tconst watcher = watch(path, listener);\n\t\twatcher.on(\"error\", onError);\n\t\treturn watcher;\n\t} catch {\n\t\tonError();\n\t\treturn null;\n\t}\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mariozechner/pi-mom",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "Slack bot that delegates messages to the pi coding agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,9 +20,9 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@anthropic-ai/sandbox-runtime": "^0.0.16",
23
- "@mariozechner/pi-agent-core": "^0.69.0",
24
- "@mariozechner/pi-ai": "^0.69.0",
25
- "@mariozechner/pi-coding-agent": "^0.69.0",
23
+ "@mariozechner/pi-agent-core": "^0.70.0",
24
+ "@mariozechner/pi-ai": "^0.70.0",
25
+ "@mariozechner/pi-coding-agent": "^0.70.0",
26
26
  "typebox": "^1.1.24",
27
27
  "@slack/socket-mode": "^2.0.0",
28
28
  "@slack/web-api": "^7.0.0",