@jeffober/thatch 0.1.34 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/artifacts/skills/thatch-code-review.md +8 -2
- package/artifacts/skills/thatch-review-context.md +15 -3
- package/artifacts/skills/thatch-review-synthesizer.md +7 -5
- package/bin/thatch +157 -0
- package/docs/dev/README.md +10 -3
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/multi-host.md +16 -10
- package/docs/dev/features/watchers.md +150 -0
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/skills.md +7 -5
- package/docs/user/README.md +25 -1
- package/docs/user/code-review.md +11 -3
- package/docs/user/skills.md +1 -1
- package/docs/user/watchers.md +79 -0
- package/package.json +1 -1
- package/src/index.ts +61 -2
- package/src/mcp.ts +4 -1
- package/src/prompts.ts +43 -7
- package/src/session-db.ts +452 -0
- package/src/skills.ts +22 -46
- package/src/tool-defs.ts +269 -3
- package/src/tools.ts +11 -3
- package/src/watchers.ts +511 -0
package/src/watchers.ts
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watchers: event-driven notifications from external sources, delivered into
|
|
3
|
+
* a live opencode session as injected prompts.
|
|
4
|
+
*
|
|
5
|
+
* The motivating case: the model registers a watcher on a GitHub PR, and
|
|
6
|
+
* thatch polls the PR in the background. When something the watcher cares
|
|
7
|
+
* about happens (new comments, commits, CI results), the plugin prompts the
|
|
8
|
+
* session with a synthetic notification part - the same delivery mechanism
|
|
9
|
+
* opencode itself uses for background task completions.
|
|
10
|
+
*
|
|
11
|
+
* Lifetime is deliberately process-scoped. The registry lives in plugin
|
|
12
|
+
* memory, never in SQLite: opencode loads thatch in-process, so the plugin
|
|
13
|
+
* process is the natural owner of its watchers. This makes multi-instance
|
|
14
|
+
* ownership structurally impossible (each process polls only what it
|
|
15
|
+
* registered), leaves no orphan data behind, and matches the extraction
|
|
16
|
+
* pipeline's opencode path, which is also in-memory. A crashed or restarted
|
|
17
|
+
* opencode loses its watchers - acceptable, because the session that created
|
|
18
|
+
* them lost its conversational context too, and a delivered notification
|
|
19
|
+
* into a session that no longer knows why it is being watched is worse than
|
|
20
|
+
* a lost watch.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The event vocabulary for the github-pr source. Each type maps to one diff
|
|
29
|
+
* against the watcher's last-seen PR state. Notifications carry pointer data
|
|
30
|
+
* (author, URL, counts) rather than content, so text authored by strangers
|
|
31
|
+
* on GitHub never enters the model's context directly - the model fetches
|
|
32
|
+
* details on demand with the gh CLI.
|
|
33
|
+
*/
|
|
34
|
+
export type WatcherEventType =
|
|
35
|
+
| "pr_comment"
|
|
36
|
+
| "pr_review_comment"
|
|
37
|
+
| "pr_review_reply"
|
|
38
|
+
| "pr_commit"
|
|
39
|
+
| "pr_status"
|
|
40
|
+
| "pr_description"
|
|
41
|
+
| "pr_ci";
|
|
42
|
+
|
|
43
|
+
export const WATCHER_EVENT_TYPES: WatcherEventType[] = [
|
|
44
|
+
"pr_comment",
|
|
45
|
+
"pr_review_comment",
|
|
46
|
+
"pr_review_reply",
|
|
47
|
+
"pr_commit",
|
|
48
|
+
"pr_status",
|
|
49
|
+
"pr_description",
|
|
50
|
+
"pr_ci",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** A single detected change, ready for delivery. Pointer data only. */
|
|
54
|
+
export interface WatcherEvent {
|
|
55
|
+
type: WatcherEventType;
|
|
56
|
+
/** Human-readable, content-free summary: who/what/where, never body text. */
|
|
57
|
+
summary: string;
|
|
58
|
+
url: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A comment seen on the PR. Only identifying fields - never comment text. */
|
|
62
|
+
export interface CommentRef {
|
|
63
|
+
id: number;
|
|
64
|
+
author: string;
|
|
65
|
+
url: string;
|
|
66
|
+
/** True when the comment is a reply to another review comment. */
|
|
67
|
+
isReply: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The last-seen state of a PR, captured at watch creation and updated each
|
|
72
|
+
* poll. Comment arrays are kept whole so each diff can name individual new
|
|
73
|
+
* comments; they are capped at the fetch page size (100).
|
|
74
|
+
*/
|
|
75
|
+
export interface PrState {
|
|
76
|
+
headSha: string;
|
|
77
|
+
state: string;
|
|
78
|
+
merged: boolean;
|
|
79
|
+
title: string;
|
|
80
|
+
/** SHA-256 of the PR body text - detects description edits without storing the body. */
|
|
81
|
+
bodySha: string;
|
|
82
|
+
lastIssueCommentId: number;
|
|
83
|
+
lastReviewCommentId: number;
|
|
84
|
+
issueComments: CommentRef[];
|
|
85
|
+
reviewComments: CommentRef[];
|
|
86
|
+
/** Check runs on the head SHA, keyed by check run id. */
|
|
87
|
+
checkRuns: Record<string, { name: string; status: string; conclusion: string | null; url: string | null }>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Watcher {
|
|
91
|
+
id: string;
|
|
92
|
+
sessionID: string;
|
|
93
|
+
/** owner/repo */
|
|
94
|
+
repo: string;
|
|
95
|
+
pr: number;
|
|
96
|
+
events: WatcherEventType[];
|
|
97
|
+
/** Epoch ms. When now > expiresAt the watcher is silently dropped. */
|
|
98
|
+
expiresAt: number;
|
|
99
|
+
createdAt: number;
|
|
100
|
+
state: PrState;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Runs one GitHub REST API path via the gh CLI and parses the JSON response. */
|
|
104
|
+
export type GhRunner = (apiPath: string) => Promise<unknown>;
|
|
105
|
+
|
|
106
|
+
export interface WatcherRegistryOptions {
|
|
107
|
+
/** Delivers a batch of events for a session. Injected so tests never spawn. */
|
|
108
|
+
deliver: (sessionID: string, events: WatcherEvent[]) => Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Gate for delivery. The plugin passes a predicate that returns true only
|
|
111
|
+
* when the session can accept a proactive prompt right now (idle, not
|
|
112
|
+
* compacting). Undeliverable events stay pending and retry on later cycles
|
|
113
|
+
* or when the session next goes idle.
|
|
114
|
+
*/
|
|
115
|
+
canDeliver: (sessionID: string) => boolean;
|
|
116
|
+
ghRunner: GhRunner;
|
|
117
|
+
pollIntervalMs?: number;
|
|
118
|
+
ttlMinutes?: number;
|
|
119
|
+
maxPerSession?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// GitHub access via the gh CLI
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The default GhRunner. Shells out to `gh api` so auth comes from the user's
|
|
128
|
+
* existing gh session - thatch never sees or stores a token. Throws on
|
|
129
|
+
* non-zero exit or unparseable output.
|
|
130
|
+
*/
|
|
131
|
+
export async function ghApiRun(apiPath: string): Promise<unknown> {
|
|
132
|
+
const proc = Bun.spawn(["gh", "api", apiPath], {
|
|
133
|
+
stdout: "pipe",
|
|
134
|
+
stderr: "pipe",
|
|
135
|
+
});
|
|
136
|
+
const timeout = setTimeout(() => proc.kill(), 15_000);
|
|
137
|
+
try {
|
|
138
|
+
const [stdout, exitCode] = await Promise.all([
|
|
139
|
+
new Response(proc.stdout).text(),
|
|
140
|
+
proc.exited,
|
|
141
|
+
]);
|
|
142
|
+
if (exitCode !== 0) {
|
|
143
|
+
const stderr = await new Response(proc.stderr).text();
|
|
144
|
+
throw new Error(`gh api ${apiPath} failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`);
|
|
145
|
+
}
|
|
146
|
+
return JSON.parse(stdout);
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let ghAvailability: boolean | null = null;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Checks whether the gh CLI is usable, once per process. A tool that fails
|
|
156
|
+
* this check reports unavailability instead of registering a watcher that
|
|
157
|
+
* can never poll.
|
|
158
|
+
*/
|
|
159
|
+
export async function ghAvailable(): Promise<boolean> {
|
|
160
|
+
if (ghAvailability !== null) return ghAvailability;
|
|
161
|
+
try {
|
|
162
|
+
const proc = Bun.spawn(["gh", "--version"], { stdout: "ignore", stderr: "ignore" });
|
|
163
|
+
const timeout = setTimeout(() => proc.kill(), 5_000);
|
|
164
|
+
const code = await proc.exited;
|
|
165
|
+
clearTimeout(timeout);
|
|
166
|
+
ghAvailability = code === 0;
|
|
167
|
+
} catch {
|
|
168
|
+
ghAvailability = false;
|
|
169
|
+
}
|
|
170
|
+
return ghAvailability;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// For testing: reset the cached availability check.
|
|
174
|
+
export function _resetGhAvailability(): void {
|
|
175
|
+
ghAvailability = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// PR state construction and diffing
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function sha256(text: string): string {
|
|
183
|
+
return Bun.SHA256.hash(text, "hex");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface RawComment {
|
|
187
|
+
id?: number;
|
|
188
|
+
user?: { login?: string };
|
|
189
|
+
html_url?: string;
|
|
190
|
+
in_reply_to_id?: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toCommentRefs(raw: unknown): CommentRef[] {
|
|
194
|
+
if (!Array.isArray(raw)) return [];
|
|
195
|
+
return raw
|
|
196
|
+
.filter((c): c is RawComment => typeof (c as RawComment).id === "number")
|
|
197
|
+
.map((c) => ({
|
|
198
|
+
id: c.id!,
|
|
199
|
+
author: c.user?.login ?? "unknown",
|
|
200
|
+
url: c.html_url ?? "",
|
|
201
|
+
isReply: typeof c.in_reply_to_id === "number",
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function maxCommentId(refs: CommentRef[]): number {
|
|
206
|
+
return refs.reduce((max, c) => Math.max(max, c.id), 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Fetches the current state of a PR plus everything the diff functions need.
|
|
211
|
+
* Four gh calls per poll per watcher: the PR itself, issue comments
|
|
212
|
+
* (top-level PR conversation), review comments (inline diff comments), and
|
|
213
|
+
* check runs on the head SHA. Comment and check-run fetches run in parallel.
|
|
214
|
+
*/
|
|
215
|
+
export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Promise<PrState> {
|
|
216
|
+
const pull = (await gh(`/repos/${repo}/pulls/${pr}`)) as {
|
|
217
|
+
head?: { sha?: string };
|
|
218
|
+
state?: string;
|
|
219
|
+
merged?: boolean;
|
|
220
|
+
title?: string;
|
|
221
|
+
body?: string | null;
|
|
222
|
+
};
|
|
223
|
+
const headSha = pull.head?.sha ?? "";
|
|
224
|
+
const [issueComments, reviewComments, checkRuns] = await Promise.all([
|
|
225
|
+
gh(`/repos/${repo}/issues/${pr}/comments?per_page=100&sort=created&direction=desc`),
|
|
226
|
+
gh(`/repos/${repo}/pulls/${pr}/comments?per_page=100&sort=created&direction=desc`),
|
|
227
|
+
gh(`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
const issueRefs = toCommentRefs(issueComments);
|
|
231
|
+
const reviewRefs = toCommentRefs(reviewComments);
|
|
232
|
+
|
|
233
|
+
const runs: PrState["checkRuns"] = {};
|
|
234
|
+
const rawRuns = (checkRuns as { check_runs?: Array<{
|
|
235
|
+
id?: number;
|
|
236
|
+
name?: string;
|
|
237
|
+
status?: string;
|
|
238
|
+
conclusion?: string | null;
|
|
239
|
+
html_url?: string | null;
|
|
240
|
+
}> }).check_runs ?? [];
|
|
241
|
+
for (const run of rawRuns) {
|
|
242
|
+
if (run.id === undefined) continue;
|
|
243
|
+
runs[String(run.id)] = {
|
|
244
|
+
name: run.name ?? "check",
|
|
245
|
+
status: run.status ?? "unknown",
|
|
246
|
+
conclusion: run.conclusion ?? null,
|
|
247
|
+
url: run.html_url ?? null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
headSha,
|
|
253
|
+
state: pull.state ?? "unknown",
|
|
254
|
+
merged: pull.merged ?? false,
|
|
255
|
+
title: pull.title ?? "",
|
|
256
|
+
bodySha: sha256(pull.body ?? ""),
|
|
257
|
+
lastIssueCommentId: maxCommentId(issueRefs),
|
|
258
|
+
lastReviewCommentId: maxCommentId(reviewRefs),
|
|
259
|
+
issueComments: issueRefs,
|
|
260
|
+
reviewComments: reviewRefs,
|
|
261
|
+
checkRuns: runs,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** At most this many events per watcher per cycle; comment floods collapse. */
|
|
266
|
+
const MAX_EVENTS_PER_DIFF = 10;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Pure diff between the last-seen and current PR state. Detects comments by
|
|
270
|
+
* id monotonicity, commits by head SHA, description by body hash, CI by
|
|
271
|
+
* check-run transitions to a completed status.
|
|
272
|
+
*/
|
|
273
|
+
export function diffPrState(before: PrState, after: PrState, url: string): WatcherEvent[] {
|
|
274
|
+
const events: WatcherEvent[] = [];
|
|
275
|
+
|
|
276
|
+
if (after.headSha !== before.headSha && after.headSha) {
|
|
277
|
+
events.push({
|
|
278
|
+
type: "pr_commit",
|
|
279
|
+
summary: `head moved to ${after.headSha.slice(0, 7)}`,
|
|
280
|
+
url,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (after.state !== before.state || after.merged !== before.merged) {
|
|
285
|
+
const status = after.merged ? "merged" : after.state;
|
|
286
|
+
events.push({ type: "pr_status", summary: `PR is now ${status}`, url });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (after.bodySha !== before.bodySha || after.title !== before.title) {
|
|
290
|
+
events.push({ type: "pr_description", summary: "PR title or description changed", url });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const fresh = (refs: CommentRef[], lastSeenId: number) =>
|
|
294
|
+
refs.filter((c) => c.id > lastSeenId);
|
|
295
|
+
|
|
296
|
+
for (const c of fresh(after.issueComments, before.lastIssueCommentId)) {
|
|
297
|
+
events.push({ type: "pr_comment", summary: `comment by ${c.author}`, url: c.url || url });
|
|
298
|
+
}
|
|
299
|
+
for (const c of fresh(after.reviewComments, before.lastReviewCommentId)) {
|
|
300
|
+
events.push({
|
|
301
|
+
type: c.isReply ? "pr_review_reply" : "pr_review_comment",
|
|
302
|
+
summary: `comment by ${c.author}`,
|
|
303
|
+
url: c.url || url,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const [runId, run] of Object.entries(after.checkRuns)) {
|
|
308
|
+
const previous = before.checkRuns[runId];
|
|
309
|
+
const completed = run.status === "completed";
|
|
310
|
+
if (!completed) continue;
|
|
311
|
+
if (previous && previous.status === "completed") continue;
|
|
312
|
+
const conclusion = run.conclusion ? ` (${run.conclusion})` : "";
|
|
313
|
+
events.push({ type: "pr_ci", summary: `check "${run.name}" completed${conclusion}`, url: run.url ?? url });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return events.slice(0, MAX_EVENTS_PER_DIFF);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Registry
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
export class WatcherRegistry {
|
|
324
|
+
#watchers = new Map<string, Watcher>();
|
|
325
|
+
/** sessionID -> events detected but not yet delivered. */
|
|
326
|
+
#pending = new Map<string, WatcherEvent[]>();
|
|
327
|
+
#timer: ReturnType<typeof setInterval> | null = null;
|
|
328
|
+
#delivering = false;
|
|
329
|
+
#polling = false;
|
|
330
|
+
readonly #opts: WatcherRegistryOptions & { pollIntervalMs: number; ttlMinutes: number; maxPerSession: number };
|
|
331
|
+
|
|
332
|
+
constructor(options: WatcherRegistryOptions) {
|
|
333
|
+
this.#opts = {
|
|
334
|
+
pollIntervalMs: Number(process.env.THATCH_WATCH_POLL_SECONDS ?? 0) * 1000 || 60_000,
|
|
335
|
+
ttlMinutes: Number(process.env.THATCH_WATCH_TTL_MINUTES ?? 0) || 480,
|
|
336
|
+
maxPerSession: Number(process.env.THATCH_WATCH_MAX_PER_SESSION ?? 0) || 5,
|
|
337
|
+
...options,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// -- Lifecycle -----------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
start(): void {
|
|
344
|
+
if (this.#timer) return;
|
|
345
|
+
this.#timer = setInterval(() => {
|
|
346
|
+
void this.poll();
|
|
347
|
+
}, this.#opts.pollIntervalMs);
|
|
348
|
+
// Never keep the host process alive just for the poller.
|
|
349
|
+
this.#timer.unref?.();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
stop(): void {
|
|
353
|
+
if (this.#timer) {
|
|
354
|
+
clearInterval(this.#timer);
|
|
355
|
+
this.#timer = null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** True while the background poller timer is armed. */
|
|
360
|
+
get running(): boolean {
|
|
361
|
+
return this.#timer !== null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Stops polling and drops all state. Called from the plugin's dispose. */
|
|
365
|
+
dispose(): void {
|
|
366
|
+
this.stop();
|
|
367
|
+
this.#watchers.clear();
|
|
368
|
+
this.#pending.clear();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// -- CRUD ----------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Registers a watcher and captures the baseline state immediately. The
|
|
375
|
+
* baseline fetch doubles as validation: a bad repo, missing PR, or broken
|
|
376
|
+
* gh setup fails here with a real error instead of a watcher that never
|
|
377
|
+
* fires.
|
|
378
|
+
*/
|
|
379
|
+
async create(
|
|
380
|
+
sessionID: string,
|
|
381
|
+
repo: string,
|
|
382
|
+
pr: number,
|
|
383
|
+
events: WatcherEventType[],
|
|
384
|
+
): Promise<{ ok: true; watcher: Watcher } | { ok: false; error: string }> {
|
|
385
|
+
const existing = this.listForSession(sessionID);
|
|
386
|
+
if (existing.length >= this.#opts.maxPerSession) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
error: `Watcher limit reached: ${this.#opts.maxPerSession} active watchers per session. Cancel one with watch_cancel first.`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let state: PrState;
|
|
394
|
+
try {
|
|
395
|
+
state = await fetchPrState(this.#opts.ghRunner, repo, pr);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
return { ok: false, error: `Failed to read ${repo}#${pr}: ${err instanceof Error ? err.message : String(err)}` };
|
|
398
|
+
}
|
|
399
|
+
if (!state.headSha) {
|
|
400
|
+
return { ok: false, error: `Could not resolve a head SHA for ${repo}#${pr}. Is it an open PR?` };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const watcher: Watcher = {
|
|
404
|
+
id: `watch_${Math.random().toString(36).slice(2, 10)}`,
|
|
405
|
+
sessionID,
|
|
406
|
+
repo,
|
|
407
|
+
pr,
|
|
408
|
+
events,
|
|
409
|
+
expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
|
|
410
|
+
createdAt: Date.now(),
|
|
411
|
+
state,
|
|
412
|
+
};
|
|
413
|
+
this.#watchers.set(watcher.id, watcher);
|
|
414
|
+
return { ok: true, watcher };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Summaries of one session's watchers, newest first. */
|
|
418
|
+
listForSession(sessionID: string): Watcher[] {
|
|
419
|
+
return [...this.#watchers.values()]
|
|
420
|
+
.filter((w) => w.sessionID === sessionID)
|
|
421
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Cancels one watcher. Returns false if it does not exist or belongs to another session. */
|
|
425
|
+
cancel(sessionID: string, id: string): boolean {
|
|
426
|
+
const watcher = this.#watchers.get(id);
|
|
427
|
+
if (!watcher || watcher.sessionID !== sessionID) return false;
|
|
428
|
+
this.#watchers.delete(id);
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Drops every watcher for a session. Called on session.deleted. */
|
|
433
|
+
cancelSession(sessionID: string): void {
|
|
434
|
+
for (const [id, w] of this.#watchers) {
|
|
435
|
+
if (w.sessionID === sessionID) this.#watchers.delete(id);
|
|
436
|
+
}
|
|
437
|
+
this.#pending.delete(sessionID);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// -- Polling and delivery ------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* One poll cycle: diff every watcher, queue events, then deliver whatever
|
|
444
|
+
* is pending for sessions that can accept a prompt. Errors are per-watcher:
|
|
445
|
+
* one bad PR never blocks the others. Reentrant calls (a cycle slower than
|
|
446
|
+
* the interval) are dropped - overlapping diffs against the same baseline
|
|
447
|
+
* would queue duplicate events.
|
|
448
|
+
*/
|
|
449
|
+
async poll(): Promise<void> {
|
|
450
|
+
if (this.#polling) return;
|
|
451
|
+
this.#polling = true;
|
|
452
|
+
try {
|
|
453
|
+
const now = Date.now();
|
|
454
|
+
for (const [id, watcher] of this.#watchers) {
|
|
455
|
+
if (now > watcher.expiresAt) {
|
|
456
|
+
this.#watchers.delete(id);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
|
|
461
|
+
const events = diffPrState(watcher.state, after, this.pullUrl(watcher))
|
|
462
|
+
.filter((e) => watcher.events.includes(e.type));
|
|
463
|
+
if (events.length > 0) {
|
|
464
|
+
const queue = this.#pending.get(watcher.sessionID) ?? [];
|
|
465
|
+
queue.push(...events);
|
|
466
|
+
this.#pending.set(watcher.sessionID, queue);
|
|
467
|
+
}
|
|
468
|
+
watcher.state = after;
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.error(`[thatch] watcher ${id} poll failed: ${err}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
await this.deliverPending();
|
|
474
|
+
} finally {
|
|
475
|
+
this.#polling = false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Delivers pending events for every session that can accept a prompt.
|
|
481
|
+
* Failed deliveries stay pending and retry on the next cycle or the next
|
|
482
|
+
* idle event.
|
|
483
|
+
*/
|
|
484
|
+
async deliverPending(): Promise<void> {
|
|
485
|
+
if (this.#delivering) return;
|
|
486
|
+
this.#delivering = true;
|
|
487
|
+
try {
|
|
488
|
+
for (const [sessionID, events] of this.#pending) {
|
|
489
|
+
if (events.length === 0) continue;
|
|
490
|
+
if (!this.#opts.canDeliver(sessionID)) continue;
|
|
491
|
+
try {
|
|
492
|
+
await this.#opts.deliver(sessionID, events);
|
|
493
|
+
this.#pending.delete(sessionID);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
console.error(`[thatch] watcher delivery to ${sessionID} failed: ${err}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
} finally {
|
|
499
|
+
this.#delivering = false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Diagnostic: number of events waiting for a session. */
|
|
504
|
+
pendingCount(sessionID: string): number {
|
|
505
|
+
return (this.#pending.get(sessionID) ?? []).length;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private pullUrl(watcher: Watcher): string {
|
|
509
|
+
return `https://github.com/${watcher.repo}/pull/${watcher.pr}`;
|
|
510
|
+
}
|
|
511
|
+
}
|