@oh-my-pi/pi-mom 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +338 -0
- package/README.md +517 -0
- package/dev.sh +30 -0
- package/docker.sh +95 -0
- package/docs/artifacts-server.md +475 -0
- package/docs/events.md +307 -0
- package/docs/new.md +977 -0
- package/docs/sandbox.md +153 -0
- package/docs/slack-bot-minimal-guide.md +399 -0
- package/docs/v86.md +319 -0
- package/package.json +45 -0
- package/scripts/migrate-timestamps.ts +121 -0
- package/src/agent.ts +887 -0
- package/src/context.ts +666 -0
- package/src/download.ts +117 -0
- package/src/events.ts +385 -0
- package/src/log.ts +271 -0
- package/src/main.ts +334 -0
- package/src/sandbox.ts +238 -0
- package/src/slack.ts +635 -0
- package/src/store.ts +253 -0
- package/src/tools/attach.ts +47 -0
- package/src/tools/bash.ts +99 -0
- package/src/tools/edit.ts +165 -0
- package/src/tools/index.ts +19 -0
- package/src/tools/read.ts +165 -0
- package/src/tools/truncate.ts +236 -0
- package/src/tools/write.ts +45 -0
- package/tsconfig.build.json +9 -0
package/src/download.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { LogLevel, WebClient } from "@slack/web-api";
|
|
2
|
+
|
|
3
|
+
interface Message {
|
|
4
|
+
ts: string;
|
|
5
|
+
user?: string;
|
|
6
|
+
text?: string;
|
|
7
|
+
thread_ts?: string;
|
|
8
|
+
reply_count?: number;
|
|
9
|
+
files?: Array<{ name: string; url_private?: string }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatTs(ts: string): string {
|
|
13
|
+
const date = new Date(parseFloat(ts) * 1000);
|
|
14
|
+
return date
|
|
15
|
+
.toISOString()
|
|
16
|
+
.replace("T", " ")
|
|
17
|
+
.replace(/\.\d+Z$/, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatMessage(ts: string, user: string, text: string, indent = ""): string {
|
|
21
|
+
const prefix = `[${formatTs(ts)}] ${user}: `;
|
|
22
|
+
const lines = text.split("\n");
|
|
23
|
+
const firstLine = `${indent}${prefix}${lines[0]}`;
|
|
24
|
+
if (lines.length === 1) return firstLine;
|
|
25
|
+
// All continuation lines get same indent as content start
|
|
26
|
+
const contentIndent = indent + " ".repeat(prefix.length);
|
|
27
|
+
return [firstLine, ...lines.slice(1).map((l) => contentIndent + l)].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function downloadChannel(channelId: string, botToken: string): Promise<void> {
|
|
31
|
+
const client = new WebClient(botToken, { logLevel: LogLevel.ERROR });
|
|
32
|
+
|
|
33
|
+
console.error(`Fetching channel info for ${channelId}...`);
|
|
34
|
+
|
|
35
|
+
// Get channel info
|
|
36
|
+
let channelName = channelId;
|
|
37
|
+
try {
|
|
38
|
+
const info = await client.conversations.info({ channel: channelId });
|
|
39
|
+
channelName = (info.channel as any)?.name || channelId;
|
|
40
|
+
} catch {
|
|
41
|
+
// DM channels don't have names, that's fine
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.error(`Downloading history for #${channelName} (${channelId})...`);
|
|
45
|
+
|
|
46
|
+
// Fetch all messages
|
|
47
|
+
const messages: Message[] = [];
|
|
48
|
+
let cursor: string | undefined;
|
|
49
|
+
|
|
50
|
+
do {
|
|
51
|
+
const response = await client.conversations.history({
|
|
52
|
+
channel: channelId,
|
|
53
|
+
limit: 200,
|
|
54
|
+
cursor,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (response.messages) {
|
|
58
|
+
messages.push(...(response.messages as Message[]));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
cursor = response.response_metadata?.next_cursor;
|
|
62
|
+
console.error(` Fetched ${messages.length} messages...`);
|
|
63
|
+
} while (cursor);
|
|
64
|
+
|
|
65
|
+
// Reverse to chronological order
|
|
66
|
+
messages.reverse();
|
|
67
|
+
|
|
68
|
+
// Build map of thread replies
|
|
69
|
+
const threadReplies = new Map<string, Message[]>();
|
|
70
|
+
const threadsToFetch = messages.filter((m) => m.reply_count && m.reply_count > 0);
|
|
71
|
+
|
|
72
|
+
console.error(`Fetching ${threadsToFetch.length} threads...`);
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < threadsToFetch.length; i++) {
|
|
75
|
+
const parent = threadsToFetch[i];
|
|
76
|
+
console.error(` Thread ${i + 1}/${threadsToFetch.length} (${parent.reply_count} replies)...`);
|
|
77
|
+
|
|
78
|
+
const replies: Message[] = [];
|
|
79
|
+
let threadCursor: string | undefined;
|
|
80
|
+
|
|
81
|
+
do {
|
|
82
|
+
const response = await client.conversations.replies({
|
|
83
|
+
channel: channelId,
|
|
84
|
+
ts: parent.ts,
|
|
85
|
+
limit: 200,
|
|
86
|
+
cursor: threadCursor,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (response.messages) {
|
|
90
|
+
// Skip the first message (it's the parent)
|
|
91
|
+
replies.push(...(response.messages as Message[]).slice(1));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
threadCursor = response.response_metadata?.next_cursor;
|
|
95
|
+
} while (threadCursor);
|
|
96
|
+
|
|
97
|
+
threadReplies.set(parent.ts, replies);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Output messages with thread replies interleaved
|
|
101
|
+
let totalReplies = 0;
|
|
102
|
+
for (const msg of messages) {
|
|
103
|
+
// Output the message
|
|
104
|
+
console.log(formatMessage(msg.ts, msg.user || "unknown", msg.text || ""));
|
|
105
|
+
|
|
106
|
+
// Output thread replies right after parent (indented)
|
|
107
|
+
const replies = threadReplies.get(msg.ts);
|
|
108
|
+
if (replies) {
|
|
109
|
+
for (const reply of replies) {
|
|
110
|
+
console.log(formatMessage(reply.ts, reply.user || "unknown", reply.text || "", " "));
|
|
111
|
+
totalReplies++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.error(`Done! ${messages.length} messages, ${totalReplies} thread replies`);
|
|
117
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { existsSync, type FSWatcher, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { logger } from "@oh-my-pi/pi-coding-agent";
|
|
5
|
+
import { Cron } from "croner";
|
|
6
|
+
import * as log from "./log";
|
|
7
|
+
import type { SlackBot, SlackEvent } from "./slack";
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Event Types
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
export interface ImmediateEvent {
|
|
14
|
+
type: "immediate";
|
|
15
|
+
channelId: string;
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OneShotEvent {
|
|
20
|
+
type: "one-shot";
|
|
21
|
+
channelId: string;
|
|
22
|
+
text: string;
|
|
23
|
+
at: string; // ISO 8601 with timezone offset
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PeriodicEvent {
|
|
27
|
+
type: "periodic";
|
|
28
|
+
channelId: string;
|
|
29
|
+
text: string;
|
|
30
|
+
schedule: string; // cron syntax
|
|
31
|
+
timezone: string; // IANA timezone
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type MomEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// EventsWatcher
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
const DEBOUNCE_MS = 100;
|
|
41
|
+
const MAX_RETRIES = 3;
|
|
42
|
+
const RETRY_BASE_MS = 100;
|
|
43
|
+
|
|
44
|
+
export class EventsWatcher {
|
|
45
|
+
private timers: Map<string, NodeJS.Timeout> = new Map();
|
|
46
|
+
private crons: Map<string, Cron> = new Map();
|
|
47
|
+
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
48
|
+
private startTime: number;
|
|
49
|
+
private watcher: FSWatcher | null = null;
|
|
50
|
+
private knownFiles: Set<string> = new Set();
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private eventsDir: string,
|
|
54
|
+
private slack: SlackBot,
|
|
55
|
+
) {
|
|
56
|
+
this.startTime = Date.now();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start watching for events. Call this after SlackBot is ready.
|
|
61
|
+
*/
|
|
62
|
+
start(): void {
|
|
63
|
+
// Ensure events directory exists
|
|
64
|
+
if (!existsSync(this.eventsDir)) {
|
|
65
|
+
mkdirSync(this.eventsDir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);
|
|
69
|
+
|
|
70
|
+
// Scan existing files
|
|
71
|
+
this.scanExisting();
|
|
72
|
+
|
|
73
|
+
// Watch for changes
|
|
74
|
+
this.watcher = watch(this.eventsDir, (_eventType, filename) => {
|
|
75
|
+
if (!filename || !filename.endsWith(".json")) return;
|
|
76
|
+
this.debounce(filename, () => this.handleFileChange(filename));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Stop watching and cancel all scheduled events.
|
|
84
|
+
*/
|
|
85
|
+
stop(): void {
|
|
86
|
+
// Stop fs watcher
|
|
87
|
+
if (this.watcher) {
|
|
88
|
+
this.watcher.close();
|
|
89
|
+
this.watcher = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Cancel all debounce timers
|
|
93
|
+
for (const timer of this.debounceTimers.values()) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
this.debounceTimers.clear();
|
|
97
|
+
|
|
98
|
+
// Cancel all scheduled timers
|
|
99
|
+
for (const timer of this.timers.values()) {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
this.timers.clear();
|
|
103
|
+
|
|
104
|
+
// Cancel all cron jobs
|
|
105
|
+
for (const cron of this.crons.values()) {
|
|
106
|
+
cron.stop();
|
|
107
|
+
}
|
|
108
|
+
this.crons.clear();
|
|
109
|
+
|
|
110
|
+
this.knownFiles.clear();
|
|
111
|
+
log.logInfo("Events watcher stopped");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private debounce(filename: string, fn: () => void): void {
|
|
115
|
+
const existing = this.debounceTimers.get(filename);
|
|
116
|
+
if (existing) {
|
|
117
|
+
clearTimeout(existing);
|
|
118
|
+
}
|
|
119
|
+
this.debounceTimers.set(
|
|
120
|
+
filename,
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
this.debounceTimers.delete(filename);
|
|
123
|
+
fn();
|
|
124
|
+
}, DEBOUNCE_MS),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private scanExisting(): void {
|
|
129
|
+
let files: string[];
|
|
130
|
+
try {
|
|
131
|
+
files = readdirSync(this.eventsDir).filter((f) => f.endsWith(".json"));
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.logWarning("Failed to read events directory", String(err));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const filename of files) {
|
|
138
|
+
this.handleFile(filename);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private handleFileChange(filename: string): void {
|
|
143
|
+
const filePath = join(this.eventsDir, filename);
|
|
144
|
+
|
|
145
|
+
if (!existsSync(filePath)) {
|
|
146
|
+
// File was deleted
|
|
147
|
+
this.handleDelete(filename);
|
|
148
|
+
} else if (this.knownFiles.has(filename)) {
|
|
149
|
+
// File was modified - cancel existing and re-schedule
|
|
150
|
+
this.cancelScheduled(filename);
|
|
151
|
+
this.handleFile(filename);
|
|
152
|
+
} else {
|
|
153
|
+
// New file
|
|
154
|
+
this.handleFile(filename);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private handleDelete(filename: string): void {
|
|
159
|
+
if (!this.knownFiles.has(filename)) return;
|
|
160
|
+
|
|
161
|
+
log.logInfo(`Event file deleted: ${filename}`);
|
|
162
|
+
this.cancelScheduled(filename);
|
|
163
|
+
this.knownFiles.delete(filename);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private cancelScheduled(filename: string): void {
|
|
167
|
+
const timer = this.timers.get(filename);
|
|
168
|
+
if (timer) {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
this.timers.delete(filename);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const cron = this.crons.get(filename);
|
|
174
|
+
if (cron) {
|
|
175
|
+
cron.stop();
|
|
176
|
+
this.crons.delete(filename);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async handleFile(filename: string): Promise<void> {
|
|
181
|
+
const filePath = join(this.eventsDir, filename);
|
|
182
|
+
|
|
183
|
+
// Mark as known immediately to prevent duplicate processing
|
|
184
|
+
this.knownFiles.add(filename);
|
|
185
|
+
|
|
186
|
+
// Parse with retries
|
|
187
|
+
let event: MomEvent | null = null;
|
|
188
|
+
let lastError: Error | null = null;
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
191
|
+
try {
|
|
192
|
+
const content = await readFile(filePath, "utf-8");
|
|
193
|
+
event = this.parseEvent(content, filename);
|
|
194
|
+
break;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
197
|
+
if (i < MAX_RETRIES - 1) {
|
|
198
|
+
await this.sleep(RETRY_BASE_MS * 2 ** i);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!event) {
|
|
204
|
+
log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
|
|
205
|
+
this.deleteFile(filename);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Schedule based on type
|
|
210
|
+
switch (event.type) {
|
|
211
|
+
case "immediate":
|
|
212
|
+
this.handleImmediate(filename, event);
|
|
213
|
+
break;
|
|
214
|
+
case "one-shot":
|
|
215
|
+
this.handleOneShot(filename, event);
|
|
216
|
+
break;
|
|
217
|
+
case "periodic":
|
|
218
|
+
this.handlePeriodic(filename, event);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private parseEvent(content: string, filename: string): MomEvent | null {
|
|
224
|
+
const data = JSON.parse(content);
|
|
225
|
+
|
|
226
|
+
if (!data.type || !data.channelId || !data.text) {
|
|
227
|
+
throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
switch (data.type) {
|
|
231
|
+
case "immediate":
|
|
232
|
+
return { type: "immediate", channelId: data.channelId, text: data.text };
|
|
233
|
+
|
|
234
|
+
case "one-shot":
|
|
235
|
+
if (!data.at) {
|
|
236
|
+
throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
|
|
237
|
+
}
|
|
238
|
+
return { type: "one-shot", channelId: data.channelId, text: data.text, at: data.at };
|
|
239
|
+
|
|
240
|
+
case "periodic":
|
|
241
|
+
if (!data.schedule) {
|
|
242
|
+
throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);
|
|
243
|
+
}
|
|
244
|
+
if (!data.timezone) {
|
|
245
|
+
throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
type: "periodic",
|
|
249
|
+
channelId: data.channelId,
|
|
250
|
+
text: data.text,
|
|
251
|
+
schedule: data.schedule,
|
|
252
|
+
timezone: data.timezone,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
default:
|
|
256
|
+
throw new Error(`Unknown event type '${data.type}' in ${filename}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private handleImmediate(filename: string, event: ImmediateEvent): void {
|
|
261
|
+
const filePath = join(this.eventsDir, filename);
|
|
262
|
+
|
|
263
|
+
// Check if stale (created before harness started)
|
|
264
|
+
try {
|
|
265
|
+
const stat = statSync(filePath);
|
|
266
|
+
if (stat.mtimeMs < this.startTime) {
|
|
267
|
+
log.logInfo(`Stale immediate event, deleting: ${filename}`);
|
|
268
|
+
this.deleteFile(filename);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
} catch (err) {
|
|
272
|
+
logger.debug("File stat failed", { filename, error: String(err) });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
log.logInfo(`Executing immediate event: ${filename}`);
|
|
277
|
+
this.execute(filename, event);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private handleOneShot(filename: string, event: OneShotEvent): void {
|
|
281
|
+
const atTime = new Date(event.at).getTime();
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
|
|
284
|
+
if (atTime <= now) {
|
|
285
|
+
// Past - delete without executing
|
|
286
|
+
log.logInfo(`One-shot event in the past, deleting: ${filename}`);
|
|
287
|
+
this.deleteFile(filename);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const delay = atTime - now;
|
|
292
|
+
log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
|
|
293
|
+
|
|
294
|
+
const timer = setTimeout(() => {
|
|
295
|
+
this.timers.delete(filename);
|
|
296
|
+
log.logInfo(`Executing one-shot event: ${filename}`);
|
|
297
|
+
this.execute(filename, event);
|
|
298
|
+
}, delay);
|
|
299
|
+
|
|
300
|
+
this.timers.set(filename, timer);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private handlePeriodic(filename: string, event: PeriodicEvent): void {
|
|
304
|
+
try {
|
|
305
|
+
const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {
|
|
306
|
+
log.logInfo(`Executing periodic event: ${filename}`);
|
|
307
|
+
this.execute(filename, event, false); // Don't delete periodic events
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
this.crons.set(filename, cron);
|
|
311
|
+
|
|
312
|
+
const next = cron.nextRun();
|
|
313
|
+
log.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? "unknown"}`);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
|
|
316
|
+
this.deleteFile(filename);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private execute(filename: string, event: MomEvent, deleteAfter: boolean = true): void {
|
|
321
|
+
// Format the message
|
|
322
|
+
let scheduleInfo: string;
|
|
323
|
+
switch (event.type) {
|
|
324
|
+
case "immediate":
|
|
325
|
+
scheduleInfo = "immediate";
|
|
326
|
+
break;
|
|
327
|
+
case "one-shot":
|
|
328
|
+
scheduleInfo = event.at;
|
|
329
|
+
break;
|
|
330
|
+
case "periodic":
|
|
331
|
+
scheduleInfo = event.schedule;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;
|
|
336
|
+
|
|
337
|
+
// Create synthetic SlackEvent
|
|
338
|
+
const syntheticEvent: SlackEvent = {
|
|
339
|
+
type: "mention",
|
|
340
|
+
channel: event.channelId,
|
|
341
|
+
user: "EVENT",
|
|
342
|
+
text: message,
|
|
343
|
+
ts: Date.now().toString(),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Enqueue for processing
|
|
347
|
+
const enqueued = this.slack.enqueueEvent(syntheticEvent);
|
|
348
|
+
|
|
349
|
+
if (enqueued && deleteAfter) {
|
|
350
|
+
// Delete file after successful enqueue (immediate and one-shot)
|
|
351
|
+
this.deleteFile(filename);
|
|
352
|
+
} else if (!enqueued) {
|
|
353
|
+
log.logWarning(`Event queue full, discarded: ${filename}`);
|
|
354
|
+
// Still delete immediate/one-shot even if discarded
|
|
355
|
+
if (deleteAfter) {
|
|
356
|
+
this.deleteFile(filename);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private deleteFile(filename: string): void {
|
|
362
|
+
const filePath = join(this.eventsDir, filename);
|
|
363
|
+
try {
|
|
364
|
+
unlinkSync(filePath);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
// ENOENT is fine (file already deleted), other errors are warnings
|
|
367
|
+
if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
|
|
368
|
+
log.logWarning(`Failed to delete event file: ${filename}`, String(err));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
this.knownFiles.delete(filename);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private sleep(ms: number): Promise<void> {
|
|
375
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Create and start an events watcher.
|
|
381
|
+
*/
|
|
382
|
+
export function createEventsWatcher(workspaceDir: string, slack: SlackBot): EventsWatcher {
|
|
383
|
+
const eventsDir = join(workspaceDir, "events");
|
|
384
|
+
return new EventsWatcher(eventsDir, slack);
|
|
385
|
+
}
|