@jeffober/thatch 0.1.35 → 0.1.37

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.
@@ -0,0 +1,787 @@
1
+ /**
2
+ * Watchers: event-driven notifications from external sources, delivered into
3
+ * a live opencode session as injected prompts.
4
+ *
5
+ * The registry supports multiple source types behind one mechanism. The
6
+ * first source was GitHub pull requests (source "pr"); GitHub branches
7
+ * (source "branch") arrived second, for watching CI and workflow runs
8
+ * against main. A source contributes: event types, a fetch function that
9
+ * produces a snapshot of the watched state, and a pure diff between two
10
+ * snapshots. Everything else - registry, pending queue, delivery gating -
11
+ * is source-agnostic.
12
+ *
13
+ * Lifetime is deliberately process-scoped. The registry lives in plugin
14
+ * memory, never in SQLite: opencode loads thatch in-process, so the plugin
15
+ * process is the natural owner of its watchers. This makes multi-instance
16
+ * ownership structurally impossible (each process polls only what it
17
+ * registered), leaves no orphan data behind, and matches the extraction
18
+ * pipeline's opencode path, which is also in-memory. A crashed or restarted
19
+ * opencode loses its watchers - acceptable, because the session that created
20
+ * them lost its conversational context too, and a delivered notification
21
+ * into a session that no longer knows why it is being watched is worse than
22
+ * a lost watch.
23
+ */
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Event types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** Event types for source "pr". */
30
+ export type PrWatcherEventType =
31
+ | "pr_comment"
32
+ | "pr_review_comment"
33
+ | "pr_review_reply"
34
+ | "pr_review_resolved"
35
+ | "pr_commit"
36
+ | "pr_status"
37
+ | "pr_description"
38
+ | "pr_ci";
39
+
40
+ /** Event types for source "branch" (typically main): commits land, CI and workflow runs execute. */
41
+ export type BranchWatcherEventType = "branch_commit" | "branch_ci" | "branch_workflow";
42
+
43
+ export type WatcherEventType = PrWatcherEventType | BranchWatcherEventType;
44
+
45
+ export const PR_EVENT_TYPES: PrWatcherEventType[] = [
46
+ "pr_comment",
47
+ "pr_review_comment",
48
+ "pr_review_reply",
49
+ "pr_review_resolved",
50
+ "pr_commit",
51
+ "pr_status",
52
+ "pr_description",
53
+ "pr_ci",
54
+ ];
55
+
56
+ export const BRANCH_EVENT_TYPES: BranchWatcherEventType[] = [
57
+ "branch_commit",
58
+ "branch_ci",
59
+ "branch_workflow",
60
+ ];
61
+
62
+ /** Every event type across all sources - for diagnostics and tests. */
63
+ export const WATCHER_EVENT_TYPES: WatcherEventType[] = [...PR_EVENT_TYPES, ...BRANCH_EVENT_TYPES];
64
+
65
+ /** A single detected change, ready for delivery. Pointer data only. */
66
+ export interface WatcherEvent {
67
+ type: WatcherEventType;
68
+ /** Which watch produced this event, e.g. "acme/widgets#7" or "acme/widgets@main". */
69
+ target: string;
70
+ /** Human-readable, content-free summary: who/what/where, never body text. */
71
+ summary: string;
72
+ url: string;
73
+ }
74
+
75
+ /** A comment seen on the PR. Only identifying fields - never comment text. */
76
+ export interface CommentRef {
77
+ id: number;
78
+ author: string;
79
+ url: string;
80
+ /** True when the comment is a reply to another review comment. */
81
+ isReply: boolean;
82
+ }
83
+
84
+ /** One CI check run, keyed by id in state snapshots. */
85
+ export interface CheckRunRef {
86
+ name: string;
87
+ status: string;
88
+ conclusion: string | null;
89
+ url: string | null;
90
+ }
91
+
92
+ /** One GitHub Actions workflow run, keyed by run id. */
93
+ export interface WorkflowRunRef {
94
+ name: string;
95
+ status: string;
96
+ conclusion: string | null;
97
+ url: string;
98
+ /** What triggered the run: push, pull_request, schedule, workflow_run, ... */
99
+ event: string;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Watchers: a discriminated union over sources
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * The last-seen state of a PR, captured at watch creation and updated each
108
+ * poll. Comment arrays are kept whole so each diff can name individual new
109
+ * comments; they are capped at the fetch page size (100).
110
+ */
111
+ export interface PrState {
112
+ headSha: string;
113
+ state: string;
114
+ merged: boolean;
115
+ title: string;
116
+ /** SHA-256 of the PR body text - detects description edits without storing the body. */
117
+ bodySha: string;
118
+ lastIssueCommentId: number;
119
+ lastReviewCommentId: number;
120
+ issueComments: CommentRef[];
121
+ reviewComments: CommentRef[];
122
+ /** Sorted ids of review threads currently in the resolved state. */
123
+ resolvedThreads: string[];
124
+ checkRuns: Record<string, CheckRunRef>;
125
+ }
126
+
127
+ /** The last-seen state of a branch. */
128
+ export interface BranchState {
129
+ headSha: string;
130
+ checkRuns: Record<string, CheckRunRef>;
131
+ /** Workflow runs on the branch, keyed by run id (last 20). */
132
+ workflowRuns: Record<string, WorkflowRunRef>;
133
+ }
134
+
135
+ export interface PrWatcher {
136
+ id: string;
137
+ source: "pr";
138
+ sessionID: string;
139
+ /** owner/repo */
140
+ repo: string;
141
+ pr: number;
142
+ events: PrWatcherEventType[];
143
+ /** Epoch ms. When now > expiresAt the watcher is silently dropped. */
144
+ expiresAt: number;
145
+ createdAt: number;
146
+ state: PrState;
147
+ }
148
+
149
+ export interface BranchWatcher {
150
+ id: string;
151
+ source: "branch";
152
+ sessionID: string;
153
+ /** owner/repo */
154
+ repo: string;
155
+ branch: string;
156
+ events: BranchWatcherEventType[];
157
+ /** Substring filters on workflow run names; empty means all workflows. */
158
+ workflows: string[];
159
+ expiresAt: number;
160
+ createdAt: number;
161
+ state: BranchState;
162
+ }
163
+
164
+ export type Watcher = PrWatcher | BranchWatcher;
165
+
166
+ /** Runs one GitHub API call via the gh CLI and parses the JSON response.
167
+ * The array is the argument list after `gh api`: a REST path like
168
+ * ["/repos/o/r/pulls/7"] or a GraphQL invocation like
169
+ * ["graphql", "-f", "query={...}"]. Array-shaped so both transports fit
170
+ * without the callers string-concatenating shell quotes. */
171
+ export type GhRunner = (apiArgs: string[]) => Promise<unknown>;
172
+
173
+ export interface WatcherRegistryOptions {
174
+ /** Delivers a batch of events for a session. Injected so tests never spawn. */
175
+ deliver: (sessionID: string, events: WatcherEvent[]) => Promise<void>;
176
+ /**
177
+ * Gate for delivery. The plugin passes a predicate that returns true only
178
+ * when the session can accept a proactive prompt right now (idle, not
179
+ * compacting). Undeliverable events stay pending and retry on later cycles
180
+ * or when the session next goes idle.
181
+ */
182
+ canDeliver: (sessionID: string) => boolean;
183
+ ghRunner: GhRunner;
184
+ pollIntervalMs?: number;
185
+ ttlMinutes?: number;
186
+ maxPerSession?: number;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // GitHub access via the gh CLI
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * The default GhRunner. Shells out to `gh api` so auth comes from the user's
195
+ * existing gh session - thatch never sees or stores a token. Throws on
196
+ * non-zero exit or unparseable output.
197
+ */
198
+ export async function ghApiRun(apiArgs: string[]): Promise<unknown> {
199
+ const proc = Bun.spawn(["gh", "api", ...apiArgs], {
200
+ stdout: "pipe",
201
+ stderr: "pipe",
202
+ });
203
+ const timeout = setTimeout(() => proc.kill(), 15_000);
204
+ try {
205
+ const [stdout, exitCode] = await Promise.all([
206
+ new Response(proc.stdout).text(),
207
+ proc.exited,
208
+ ]);
209
+ if (exitCode !== 0) {
210
+ const stderr = await new Response(proc.stderr).text();
211
+ throw new Error(`gh api ${apiArgs.join(" ")} failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`);
212
+ }
213
+ return JSON.parse(stdout);
214
+ } finally {
215
+ clearTimeout(timeout);
216
+ }
217
+ }
218
+
219
+ let ghAvailability: boolean | null = null;
220
+
221
+ /**
222
+ * Checks whether the gh CLI is usable, once per process. A tool that fails
223
+ * this check reports unavailability instead of registering a watcher that
224
+ * can never poll.
225
+ */
226
+ export async function ghAvailable(): Promise<boolean> {
227
+ if (ghAvailability !== null) return ghAvailability;
228
+ try {
229
+ const proc = Bun.spawn(["gh", "--version"], { stdout: "ignore", stderr: "ignore" });
230
+ const timeout = setTimeout(() => proc.kill(), 5_000);
231
+ const code = await proc.exited;
232
+ clearTimeout(timeout);
233
+ ghAvailability = code === 0;
234
+ } catch {
235
+ ghAvailability = false;
236
+ }
237
+ return ghAvailability;
238
+ }
239
+
240
+ // For testing: reset the cached availability check.
241
+ export function _resetGhAvailability(): void {
242
+ ghAvailability = null;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Snapshot construction
247
+ // ---------------------------------------------------------------------------
248
+
249
+ function sha256(text: string): string {
250
+ return Bun.SHA256.hash(text, "hex");
251
+ }
252
+
253
+ interface RawComment {
254
+ id?: number;
255
+ user?: { login?: string };
256
+ html_url?: string;
257
+ in_reply_to_id?: number;
258
+ }
259
+
260
+ function toCommentRefs(raw: unknown): CommentRef[] {
261
+ if (!Array.isArray(raw)) return [];
262
+ return raw
263
+ .filter((c): c is RawComment => typeof (c as RawComment).id === "number")
264
+ .map((c) => ({
265
+ id: c.id!,
266
+ author: c.user?.login ?? "unknown",
267
+ url: c.html_url ?? "",
268
+ isReply: typeof c.in_reply_to_id === "number",
269
+ }));
270
+ }
271
+
272
+ function maxCommentId(refs: CommentRef[]): number {
273
+ return refs.reduce((max, c) => Math.max(max, c.id), 0);
274
+ }
275
+
276
+ function parseCheckRuns(raw: unknown): Record<string, CheckRunRef> {
277
+ const runs: Record<string, CheckRunRef> = {};
278
+ const rawRuns = (raw as { check_runs?: Array<{
279
+ id?: number;
280
+ name?: string;
281
+ status?: string;
282
+ conclusion?: string | null;
283
+ html_url?: string | null;
284
+ }> }).check_runs ?? [];
285
+ for (const run of rawRuns) {
286
+ if (run.id === undefined) continue;
287
+ runs[String(run.id)] = {
288
+ name: run.name ?? "check",
289
+ status: run.status ?? "unknown",
290
+ conclusion: run.conclusion ?? null,
291
+ url: run.html_url ?? null,
292
+ };
293
+ }
294
+ return runs;
295
+ }
296
+
297
+ function parseWorkflowRuns(raw: unknown): Record<string, WorkflowRunRef> {
298
+ const runs: Record<string, WorkflowRunRef> = {};
299
+ const rawRuns = (raw as { workflow_runs?: Array<{
300
+ id?: number;
301
+ name?: string;
302
+ status?: string;
303
+ conclusion?: string | null;
304
+ html_url?: string;
305
+ event?: string;
306
+ }> }).workflow_runs ?? [];
307
+ for (const run of rawRuns) {
308
+ if (run.id === undefined) continue;
309
+ runs[String(run.id)] = {
310
+ name: run.name ?? "workflow",
311
+ status: run.status ?? "unknown",
312
+ conclusion: run.conclusion ?? null,
313
+ url: run.html_url ?? "",
314
+ event: run.event ?? "unknown",
315
+ };
316
+ }
317
+ return runs;
318
+ }
319
+
320
+ function parseResolvedThreads(raw: unknown): string[] {
321
+ const nodes = (raw as {
322
+ data?: { repository?: { pullRequest?: { reviewThreads?: { nodes?: Array<{ id?: string; isResolved?: boolean }> } } } };
323
+ }).data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
324
+ return nodes
325
+ .filter((n) => n.id && n.isResolved)
326
+ .map((n) => n.id!)
327
+ .sort();
328
+ }
329
+
330
+ /**
331
+ * Fetches the current state of a PR plus everything the diff functions need.
332
+ * Five gh calls per poll per watcher: the PR itself, issue comments
333
+ * (top-level PR conversation), review comments (inline diff comments), check
334
+ * runs on the head SHA, and one GraphQL query for review-thread resolution
335
+ * state (isResolved exists only in GraphQL, not the REST API). All
336
+ * non-pull fetches run in parallel.
337
+ */
338
+ export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Promise<PrState> {
339
+ const pull = (await gh([`/repos/${repo}/pulls/${pr}`])) as {
340
+ head?: { sha?: string };
341
+ state?: string;
342
+ merged?: boolean;
343
+ title?: string;
344
+ body?: string | null;
345
+ };
346
+ const headSha = pull.head?.sha ?? "";
347
+ const [owner, repoName] = repo.split("/");
348
+ const graphqlQuery =
349
+ `query { repository(owner: "${owner}", name: "${repoName}") { ` +
350
+ `pullRequest(number: ${pr}) { reviewThreads(first: 100) { nodes { id isResolved } } } } }`;
351
+ const [issueComments, reviewComments, checkRuns, threads] = await Promise.all([
352
+ gh([`/repos/${repo}/issues/${pr}/comments?per_page=100&sort=created&direction=desc`]),
353
+ gh([`/repos/${repo}/pulls/${pr}/comments?per_page=100&sort=created&direction=desc`]),
354
+ gh([`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`]),
355
+ gh(["graphql", "-f", `query=${graphqlQuery}`]),
356
+ ]);
357
+
358
+ const issueRefs = toCommentRefs(issueComments);
359
+ const reviewRefs = toCommentRefs(reviewComments);
360
+
361
+ return {
362
+ headSha,
363
+ state: pull.state ?? "unknown",
364
+ merged: pull.merged ?? false,
365
+ title: pull.title ?? "",
366
+ bodySha: sha256(pull.body ?? ""),
367
+ lastIssueCommentId: maxCommentId(issueRefs),
368
+ lastReviewCommentId: maxCommentId(reviewRefs),
369
+ issueComments: issueRefs,
370
+ reviewComments: reviewRefs,
371
+ resolvedThreads: parseResolvedThreads(threads),
372
+ checkRuns: parseCheckRuns(checkRuns),
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Fetches the current state of a branch: the head commit, check runs on that
378
+ * head, and the branch's recent workflow runs (last 20). The branch and
379
+ * workflow-run calls run in parallel; check runs need the head SHA first.
380
+ */
381
+ export async function fetchBranchState(gh: GhRunner, repo: string, branch: string): Promise<BranchState> {
382
+ const [branchInfo, runs] = await Promise.all([
383
+ gh([`/repos/${repo}/branches/${branch}`]),
384
+ gh([`/repos/${repo}/actions/runs?branch=${branch}&per_page=20`]),
385
+ ]);
386
+ const headSha = (branchInfo as { commit?: { sha?: string } }).commit?.sha ?? "";
387
+ const checkRuns = await gh([`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`]);
388
+ return {
389
+ headSha,
390
+ checkRuns: parseCheckRuns(checkRuns),
391
+ workflowRuns: parseWorkflowRuns(runs),
392
+ };
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Diffing
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /** At most this many events per watcher per cycle; comment floods collapse. */
400
+ const MAX_EVENTS_PER_DIFF = 10;
401
+
402
+ /**
403
+ * Shared check-run diff: emits one event per run that reaches a completed
404
+ * status. A run that was already completed before produces nothing; a run
405
+ * that appears between polls already completed produces one.
406
+ */
407
+ function diffCheckRuns(
408
+ before: Record<string, CheckRunRef>,
409
+ after: Record<string, CheckRunRef>,
410
+ eventType: "pr_ci" | "branch_ci",
411
+ fallbackUrl: string,
412
+ out: WatcherEvent[],
413
+ ): void {
414
+ for (const [runId, run] of Object.entries(after)) {
415
+ const previous = before[runId];
416
+ if (run.status !== "completed") continue;
417
+ if (previous && previous.status === "completed") continue;
418
+ const conclusion = run.conclusion ? ` (${run.conclusion})` : "";
419
+ out.push({
420
+ type: eventType,
421
+ target: "",
422
+ summary: `check "${run.name}" completed${conclusion}`,
423
+ url: run.url ?? fallbackUrl,
424
+ });
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Pure diff between the last-seen and current PR state. Detects comments by
430
+ * id monotonicity, commits by head SHA, description by body hash, CI by
431
+ * check-run transitions, thread resolution by symmetric difference of the
432
+ * sorted resolved-id lists.
433
+ */
434
+ export function diffPrState(before: PrState, after: PrState, target: string, baseUrl: string): WatcherEvent[] {
435
+ const events: WatcherEvent[] = [];
436
+
437
+ if (after.headSha !== before.headSha && after.headSha) {
438
+ events.push({ type: "pr_commit", target, summary: `head moved to ${after.headSha.slice(0, 7)}`, url: baseUrl });
439
+ }
440
+
441
+ if (after.state !== before.state || after.merged !== before.merged) {
442
+ const status = after.merged ? "merged" : after.state;
443
+ events.push({ type: "pr_status", target, summary: `PR is now ${status}`, url: baseUrl });
444
+ }
445
+
446
+ if (after.bodySha !== before.bodySha || after.title !== before.title) {
447
+ events.push({ type: "pr_description", target, summary: "PR title or description changed", url: baseUrl });
448
+ }
449
+
450
+ const fresh = (refs: CommentRef[], lastSeenId: number) =>
451
+ refs.filter((c) => c.id > lastSeenId);
452
+
453
+ for (const c of fresh(after.issueComments, before.lastIssueCommentId)) {
454
+ events.push({ type: "pr_comment", target, summary: `comment by ${c.author}`, url: c.url || baseUrl });
455
+ }
456
+ for (const c of fresh(after.reviewComments, before.lastReviewCommentId)) {
457
+ events.push({
458
+ type: c.isReply ? "pr_review_reply" : "pr_review_comment",
459
+ target,
460
+ summary: `comment by ${c.author}`,
461
+ url: c.url || baseUrl,
462
+ });
463
+ }
464
+
465
+ const afterResolved = new Set(after.resolvedThreads);
466
+ const beforeResolved = new Set(before.resolvedThreads);
467
+ for (const id of after.resolvedThreads) {
468
+ if (!beforeResolved.has(id)) {
469
+ events.push({ type: "pr_review_resolved", target, summary: "review thread resolved", url: baseUrl });
470
+ }
471
+ }
472
+ for (const id of before.resolvedThreads) {
473
+ if (!afterResolved.has(id)) {
474
+ events.push({ type: "pr_review_resolved", target, summary: "review thread reopened", url: baseUrl });
475
+ }
476
+ }
477
+
478
+ diffCheckRuns(before.checkRuns, after.checkRuns, "pr_ci", baseUrl, events);
479
+
480
+ return events.slice(0, MAX_EVENTS_PER_DIFF);
481
+ }
482
+
483
+ /**
484
+ * Pure diff between the last-seen and current branch state: head movement,
485
+ * check-run completions on the head, and workflow runs appearing or
486
+ * transitioning on the branch.
487
+ */
488
+ export function diffBranchState(
489
+ before: BranchState,
490
+ after: BranchState,
491
+ target: string,
492
+ repo: string,
493
+ ): WatcherEvent[] {
494
+ const events: WatcherEvent[] = [];
495
+
496
+ if (after.headSha !== before.headSha && after.headSha) {
497
+ events.push({
498
+ type: "branch_commit",
499
+ target,
500
+ summary: `head moved to ${after.headSha.slice(0, 7)}`,
501
+ url: `https://github.com/${repo}/commit/${after.headSha}`,
502
+ });
503
+ }
504
+
505
+ diffCheckRuns(before.checkRuns, after.checkRuns, "branch_ci", `https://github.com/${repo}/actions`, events);
506
+
507
+ for (const [runId, run] of Object.entries(after.workflowRuns)) {
508
+ const previous = before.workflowRuns[runId];
509
+ const transition = !previous
510
+ ? `started (${run.status})`
511
+ : previous.status !== run.status
512
+ ? run.status === "completed"
513
+ ? `completed (${run.conclusion ?? "no conclusion"})`
514
+ : `${previous.status} -> ${run.status}`
515
+ : null;
516
+ if (!transition) continue;
517
+ events.push({
518
+ type: "branch_workflow",
519
+ target,
520
+ summary: `workflow "${run.name}" ${transition} [${run.event}]`,
521
+ url: run.url,
522
+ });
523
+ }
524
+
525
+ return events.slice(0, MAX_EVENTS_PER_DIFF);
526
+ }
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // Registry
530
+ // ---------------------------------------------------------------------------
531
+
532
+ export class WatcherRegistry {
533
+ #watchers = new Map<string, Watcher>();
534
+ /** sessionID -> events detected but not yet delivered. */
535
+ #pending = new Map<string, WatcherEvent[]>();
536
+ #timer: ReturnType<typeof setInterval> | null = null;
537
+ #delivering = false;
538
+ #polling = false;
539
+ readonly #opts: WatcherRegistryOptions & { pollIntervalMs: number; ttlMinutes: number; maxPerSession: number };
540
+
541
+ constructor(options: WatcherRegistryOptions) {
542
+ this.#opts = {
543
+ pollIntervalMs: Number(process.env.THATCH_WATCH_POLL_SECONDS ?? 0) * 1000 || 60_000,
544
+ ttlMinutes: Number(process.env.THATCH_WATCH_TTL_MINUTES ?? 0) || 480,
545
+ maxPerSession: Number(process.env.THATCH_WATCH_MAX_PER_SESSION ?? 0) || 5,
546
+ ...options,
547
+ };
548
+ }
549
+
550
+ // -- Lifecycle -----------------------------------------------------------
551
+
552
+ start(): void {
553
+ if (this.#timer) return;
554
+ this.#timer = setInterval(() => {
555
+ void this.poll();
556
+ }, this.#opts.pollIntervalMs);
557
+ // Never keep the host process alive just for the poller.
558
+ this.#timer.unref?.();
559
+ }
560
+
561
+ stop(): void {
562
+ if (this.#timer) {
563
+ clearInterval(this.#timer);
564
+ this.#timer = null;
565
+ }
566
+ }
567
+
568
+ /** True while the background poller timer is armed. */
569
+ get running(): boolean {
570
+ return this.#timer !== null;
571
+ }
572
+
573
+ /** Stops polling and drops all state. Called from the plugin's dispose. */
574
+ dispose(): void {
575
+ this.stop();
576
+ this.#watchers.clear();
577
+ this.#pending.clear();
578
+ }
579
+
580
+ // -- CRUD ----------------------------------------------------------------
581
+
582
+ /**
583
+ * Registers a PR watcher and captures the baseline state immediately. The
584
+ * baseline fetch doubles as validation: a bad repo, missing PR, or broken
585
+ * gh setup fails here with a real error instead of a watcher that never
586
+ * fires.
587
+ */
588
+ async createPr(
589
+ sessionID: string,
590
+ repo: string,
591
+ pr: number,
592
+ events: PrWatcherEventType[],
593
+ ): Promise<{ ok: true; watcher: PrWatcher } | { ok: false; error: string }> {
594
+ const limit = this.#checkLimit(sessionID);
595
+ if (limit) return { ok: false, error: limit };
596
+ if (events.length === 0) return { ok: false, error: "No events to watch - pass at least one event type." };
597
+
598
+ let state: PrState;
599
+ try {
600
+ state = await fetchPrState(this.#opts.ghRunner, repo, pr);
601
+ } catch (err) {
602
+ return { ok: false, error: `Failed to read ${repo}#${pr}: ${err instanceof Error ? err.message : String(err)}` };
603
+ }
604
+ if (!state.headSha) {
605
+ return { ok: false, error: `Could not resolve a head SHA for ${repo}#${pr}. Is it an open PR?` };
606
+ }
607
+
608
+ const watcher: PrWatcher = {
609
+ id: `watch_${Math.random().toString(36).slice(2, 10)}`,
610
+ source: "pr",
611
+ sessionID,
612
+ repo,
613
+ pr,
614
+ events,
615
+ expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
616
+ createdAt: Date.now(),
617
+ state,
618
+ };
619
+ this.#watchers.set(watcher.id, watcher);
620
+ return { ok: true, watcher };
621
+ }
622
+
623
+ /**
624
+ * Registers a branch watcher (typically main): commit landings, check-run
625
+ * completions on the head, and workflow runs on the branch. Same baseline
626
+ * validation as createPr.
627
+ */
628
+ async createBranch(
629
+ sessionID: string,
630
+ repo: string,
631
+ branch: string,
632
+ events: BranchWatcherEventType[],
633
+ workflows: string[] = [],
634
+ ): Promise<{ ok: true; watcher: BranchWatcher } | { ok: false; error: string }> {
635
+ const limit = this.#checkLimit(sessionID);
636
+ if (limit) return { ok: false, error: limit };
637
+ if (events.length === 0) return { ok: false, error: "No events to watch - pass at least one event type." };
638
+
639
+ let state: BranchState;
640
+ try {
641
+ state = await fetchBranchState(this.#opts.ghRunner, repo, branch);
642
+ } catch (err) {
643
+ return { ok: false, error: `Failed to read ${repo}@${branch}: ${err instanceof Error ? err.message : String(err)}` };
644
+ }
645
+ if (!state.headSha) {
646
+ return { ok: false, error: `Could not resolve a head SHA for ${repo}@${branch}. Does the branch exist and have commits?` };
647
+ }
648
+
649
+ const watcher: BranchWatcher = {
650
+ id: `watch_${Math.random().toString(36).slice(2, 10)}`,
651
+ source: "branch",
652
+ sessionID,
653
+ repo,
654
+ branch,
655
+ events,
656
+ workflows,
657
+ expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
658
+ createdAt: Date.now(),
659
+ state,
660
+ };
661
+ this.#watchers.set(watcher.id, watcher);
662
+ return { ok: true, watcher };
663
+ }
664
+
665
+ #checkLimit(sessionID: string): string | null {
666
+ const existing = this.listForSession(sessionID);
667
+ if (existing.length >= this.#opts.maxPerSession) {
668
+ return `Watcher limit reached: ${this.#opts.maxPerSession} active watchers per session. Cancel one with watch_cancel first.`;
669
+ }
670
+ return null;
671
+ }
672
+
673
+ /** Summaries of one session's watchers, newest first. */
674
+ listForSession(sessionID: string): Watcher[] {
675
+ return [...this.#watchers.values()]
676
+ .filter((w) => w.sessionID === sessionID)
677
+ .sort((a, b) => b.createdAt - a.createdAt);
678
+ }
679
+
680
+ /** Cancels one watcher. Returns false if it does not exist or belongs to another session. */
681
+ cancel(sessionID: string, id: string): boolean {
682
+ const watcher = this.#watchers.get(id);
683
+ if (!watcher || watcher.sessionID !== sessionID) return false;
684
+ this.#watchers.delete(id);
685
+ return true;
686
+ }
687
+
688
+ /** Drops every watcher for a session. Called on session.deleted. */
689
+ cancelSession(sessionID: string): void {
690
+ for (const [id, w] of this.#watchers) {
691
+ if (w.sessionID === sessionID) this.#watchers.delete(id);
692
+ }
693
+ this.#pending.delete(sessionID);
694
+ }
695
+
696
+ // -- Polling and delivery ------------------------------------------------
697
+
698
+ /**
699
+ * One poll cycle: diff every watcher, queue events, then deliver whatever
700
+ * is pending for sessions that can accept a prompt. Errors are per-watcher:
701
+ * one bad target never blocks the others. Reentrant calls (a cycle slower
702
+ * than the interval) are dropped - overlapping diffs against the same
703
+ * baseline would queue duplicate events.
704
+ */
705
+ async poll(): Promise<void> {
706
+ if (this.#polling) return;
707
+ this.#polling = true;
708
+ try {
709
+ const now = Date.now();
710
+ for (const [id, watcher] of this.#watchers) {
711
+ if (now > watcher.expiresAt) {
712
+ this.#watchers.delete(id);
713
+ continue;
714
+ }
715
+ try {
716
+ const events = await this.#pollOne(watcher);
717
+ if (events.length > 0) {
718
+ const queue = this.#pending.get(watcher.sessionID) ?? [];
719
+ queue.push(...events);
720
+ this.#pending.set(watcher.sessionID, queue);
721
+ }
722
+ } catch (err) {
723
+ console.error(`[thatch] watcher ${id} poll failed: ${err}`);
724
+ }
725
+ }
726
+ await this.deliverPending();
727
+ } finally {
728
+ this.#polling = false;
729
+ }
730
+ }
731
+
732
+ /** Fetches and diffs one watcher, filtered to its watched event types. */
733
+ async #pollOne(watcher: Watcher): Promise<WatcherEvent[]> {
734
+ if (watcher.source === "pr") {
735
+ const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
736
+ const events = diffPrState(watcher.state, after, `${watcher.repo}#${watcher.pr}`, this.#prUrl(watcher))
737
+ .filter((e) => watcher.events.includes(e.type as PrWatcherEventType));
738
+ watcher.state = after;
739
+ return events;
740
+ }
741
+ const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
742
+ const all = diffBranchState(watcher.state, after, `${watcher.repo}@${watcher.branch}`, watcher.repo);
743
+ watcher.state = after;
744
+ // The workflow-name filter narrows branch_workflow events only -
745
+ // commits and check runs pass through unfiltered, or a watch filtered
746
+ // to "Publish" would silently drop its commit and CI notifications.
747
+ const matchesFilter = (e: WatcherEvent) =>
748
+ e.type !== "branch_workflow" ||
749
+ watcher.workflows.length === 0 ||
750
+ watcher.workflows.some((wf) => e.summary.toLowerCase().includes(wf.toLowerCase()));
751
+ return all
752
+ .filter((e) => watcher.events.includes(e.type as BranchWatcherEventType) && matchesFilter(e));
753
+ }
754
+
755
+ /**
756
+ * Delivers pending events for every session that can accept a prompt.
757
+ * Failed deliveries stay pending and retry on the next cycle or the next
758
+ * idle event.
759
+ */
760
+ async deliverPending(): Promise<void> {
761
+ if (this.#delivering) return;
762
+ this.#delivering = true;
763
+ try {
764
+ for (const [sessionID, events] of this.#pending) {
765
+ if (events.length === 0) continue;
766
+ if (!this.#opts.canDeliver(sessionID)) continue;
767
+ try {
768
+ await this.#opts.deliver(sessionID, events);
769
+ this.#pending.delete(sessionID);
770
+ } catch (err) {
771
+ console.error(`[thatch] watcher delivery to ${sessionID} failed: ${err}`);
772
+ }
773
+ }
774
+ } finally {
775
+ this.#delivering = false;
776
+ }
777
+ }
778
+
779
+ /** Diagnostic: number of events waiting for a session. */
780
+ pendingCount(sessionID: string): number {
781
+ return (this.#pending.get(sessionID) ?? []).length;
782
+ }
783
+
784
+ #prUrl(watcher: PrWatcher): string {
785
+ return `https://github.com/${watcher.repo}/pull/${watcher.pr}`;
786
+ }
787
+ }