@app-vitals/agent-runtime 0.0.1 → 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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,462 @@
1
+ /**
2
+ * agent/src/runtime/github-installations.ts
3
+ *
4
+ * GitHub App installation discovery and selection. discoverInstallations()
5
+ * lists every installation of the App (App JWT only, paginated);
6
+ * selectInstallations() is a pure function that narrows that list to the
7
+ * installations this agent should use. Built for dependency injection
8
+ * (injected fetchFn) so it's testable without a live GitHub App.
9
+ */
10
+
11
+ import { type Clock, SystemClock } from "./clock.ts";
12
+ import { type FetchFn, GitHubTokenManager } from "./github-app-auth.ts";
13
+
14
+ // ─── Types ────────────────────────────────────────────────────────────────────
15
+
16
+ export interface DiscoveredInstallation {
17
+ id: number;
18
+ /** Account login that owns the installation (org or user). */
19
+ owner: string;
20
+ /** Non-null when GitHub has suspended the installation. */
21
+ suspendedAt: string | null;
22
+ }
23
+
24
+ type DiscoveryResult =
25
+ | { ok: true; installations: DiscoveredInstallation[] }
26
+ | { ok: false; error: string };
27
+
28
+ interface SelectedInstallation {
29
+ id: number;
30
+ /** null only for a pinned id GitHub did not return from discovery. */
31
+ owner: string | null;
32
+ suspended: boolean;
33
+ pinned: boolean;
34
+ }
35
+
36
+ interface InstallationSelection {
37
+ installations: SelectedInstallation[];
38
+ /** The pin if set, else the lowest selected id; null when nothing selected. */
39
+ defaultId: number | null;
40
+ }
41
+
42
+ const INSTALLATIONS_URL = "https://api.github.com/app/installations";
43
+ const PER_PAGE = 100;
44
+ // Guard against a misbehaving server that never returns a short page.
45
+ const MAX_PAGES = 100;
46
+
47
+ // ─── Discovery ────────────────────────────────────────────────────────────────
48
+
49
+ interface RawInstallation {
50
+ id: number;
51
+ account?: { login?: string } | null;
52
+ suspended_at?: string | null;
53
+ }
54
+
55
+ /**
56
+ * Lists all installations of the App via GET /app/installations, following
57
+ * pagination at 100 per page. Never throws — any failure is returned as an
58
+ * error result so the caller can keep its last good list.
59
+ */
60
+ export async function discoverInstallations(
61
+ appJwt: string,
62
+ fetchFn: FetchFn = fetch,
63
+ ): Promise<DiscoveryResult> {
64
+ const installations: DiscoveredInstallation[] = [];
65
+ try {
66
+ for (let page = 1; page <= MAX_PAGES; page++) {
67
+ const resp = await fetchFn(
68
+ `${INSTALLATIONS_URL}?per_page=${PER_PAGE}&page=${page}`,
69
+ {
70
+ headers: {
71
+ Authorization: `Bearer ${appJwt}`,
72
+ Accept: "application/vnd.github+json",
73
+ "X-GitHub-Api-Version": "2022-11-28",
74
+ },
75
+ },
76
+ );
77
+ if (!resp.ok) {
78
+ return {
79
+ ok: false,
80
+ error: `GET /app/installations failed: ${resp.status} ${resp.statusText}`,
81
+ };
82
+ }
83
+ const raw = (await resp.json()) as RawInstallation[];
84
+ if (!Array.isArray(raw)) {
85
+ return {
86
+ ok: false,
87
+ error: "GET /app/installations returned a non-array body",
88
+ };
89
+ }
90
+ for (const r of raw) {
91
+ installations.push({
92
+ id: r.id,
93
+ owner: r.account?.login ?? "",
94
+ suspendedAt: r.suspended_at ?? null,
95
+ });
96
+ }
97
+ if (raw.length < PER_PAGE) {
98
+ return { ok: true, installations };
99
+ }
100
+ }
101
+ return {
102
+ ok: false,
103
+ error: `GET /app/installations exceeded ${MAX_PAGES} pages`,
104
+ };
105
+ } catch (err) {
106
+ return {
107
+ ok: false,
108
+ error: err instanceof Error ? err.message : String(err),
109
+ };
110
+ }
111
+ }
112
+
113
+ // ─── Selection ────────────────────────────────────────────────────────────────
114
+
115
+ interface SelectInstallationsInput {
116
+ discovered: DiscoveredInstallation[];
117
+ /** Scoped repos as `org/repo` strings (agentReposRef.get()). */
118
+ scopedRepos: string[];
119
+ /** agentReposRef.hasSynced() — false means scope is unknown. */
120
+ scopeSynced: boolean;
121
+ /** GH_APP_INSTALLATION_ID, if set. */
122
+ pinnedId?: number | null;
123
+ }
124
+
125
+ /**
126
+ * Pure selection. An installation is used only if its owner is in repo scope
127
+ * (case-insensitive) or it is the pinned id. A pinned id wins over any other
128
+ * discovered id for the same owner. Unsynced scope means pinned-only (never an
129
+ * empty set when a pin exists).
130
+ */
131
+ export function selectInstallations(
132
+ input: SelectInstallationsInput,
133
+ ): InstallationSelection {
134
+ const { discovered, scopedRepos, scopeSynced } = input;
135
+ const pinnedId = input.pinnedId ?? null;
136
+
137
+ const scopedOwners = new Set(
138
+ scopedRepos.map((r) => r.split("/")[0]?.toLowerCase()).filter(Boolean),
139
+ );
140
+
141
+ const byOwner = new Map<string, SelectedInstallation>();
142
+ const pinnedFound = discovered.find((d) => d.id === pinnedId);
143
+
144
+ for (const d of discovered) {
145
+ const key = d.owner.toLowerCase();
146
+ const isPinned = d.id === pinnedId;
147
+ if (!isPinned && (!scopeSynced || !scopedOwners.has(key))) continue;
148
+
149
+ const existing = byOwner.get(key);
150
+ if (existing?.pinned) continue;
151
+ if (!isPinned && existing && existing.id < d.id) continue;
152
+ byOwner.set(key, {
153
+ id: d.id,
154
+ owner: d.owner,
155
+ suspended: d.suspendedAt !== null,
156
+ pinned: isPinned,
157
+ });
158
+ }
159
+
160
+ const installations = [...byOwner.values()];
161
+ if (pinnedId !== null && !pinnedFound) {
162
+ installations.push({
163
+ id: pinnedId,
164
+ owner: null,
165
+ suspended: false,
166
+ pinned: true,
167
+ });
168
+ }
169
+ installations.sort((a, b) => a.id - b.id);
170
+
171
+ const defaultId =
172
+ pinnedId !== null
173
+ ? pinnedId
174
+ : installations.length > 0
175
+ ? installations[0].id
176
+ : null;
177
+
178
+ return { installations, defaultId };
179
+ }
180
+
181
+ // ─── Manager ──────────────────────────────────────────────────────────────────
182
+
183
+ type ManagerAuthFn = ConstructorParameters<
184
+ typeof GitHubTokenManager
185
+ >[0]["auth"];
186
+
187
+ /** Status classes carried on mint-failed events — never an error message. */
188
+ type MintFailureClass = "4xx" | "5xx" | "network" | "other";
189
+
190
+ export type InstallationEvent =
191
+ | { type: "discovered"; installationId: number; owner: string | null }
192
+ | { type: "removed"; installationId: number }
193
+ | { type: "minted"; installationId: number }
194
+ | {
195
+ type: "mint_failed";
196
+ installationId: number;
197
+ statusClass: MintFailureClass;
198
+ status: number | null;
199
+ broken: boolean;
200
+ }
201
+ | { type: "discovery_failed" };
202
+
203
+ type InstallationHealth = "ok" | "broken" | "unknown";
204
+
205
+ export interface InstallationState {
206
+ id: number;
207
+ owner: string | null;
208
+ pinned: boolean;
209
+ health: InstallationHealth;
210
+ /** Why the installation is broken; never contains token or key material. */
211
+ reason: "suspended" | "mint_rejected" | null;
212
+ }
213
+
214
+ export interface InstallationsSnapshot {
215
+ installations: InstallationState[];
216
+ defaultId: number | null;
217
+ }
218
+
219
+ interface GitHubInstallationsManagerOptions {
220
+ /** @octokit/auth-app auth function (App JWT via `type: "app"`). */
221
+ auth: ManagerAuthFn;
222
+ fetchFn?: FetchFn;
223
+ clock?: Clock;
224
+ setIntervalFn?: typeof setInterval;
225
+ clearIntervalFn?: typeof clearInterval;
226
+ pinnedId?: number | null;
227
+ refreshIntervalMs?: number;
228
+ onEvent?: (event: InstallationEvent) => void;
229
+ onChange?: (state: InstallationsSnapshot) => void;
230
+ }
231
+
232
+ // Shorter than GitHubTokenManager's REFRESH_BUFFER_MS so a tick always lands
233
+ // inside the pre-expiry window and re-mints before the token lapses.
234
+ const MANAGER_REFRESH_INTERVAL_MS = 4 * 60 * 1000;
235
+ const BROKEN_STATUSES = new Set([403, 404, 422]);
236
+
237
+ function failureClass(status: number | null): MintFailureClass {
238
+ if (status === null) return "network";
239
+ if (status >= 400 && status < 500) return "4xx";
240
+ if (status >= 500 && status < 600) return "5xx";
241
+ return "other";
242
+ }
243
+
244
+ function statusOf(err: unknown): number | null {
245
+ const s = (err as { status?: unknown } | null)?.status;
246
+ return typeof s === "number" ? s : null;
247
+ }
248
+
249
+ interface Tracked {
250
+ state: InstallationState;
251
+ tokens: GitHubTokenManager;
252
+ lastToken: string | null;
253
+ }
254
+
255
+ /**
256
+ * Composes one GitHubTokenManager per selected installation behind a single
257
+ * refresh timer. A broken installation (suspended, or mint rejected with
258
+ * 403/404/422) never affects the others; transient mint errors (5xx, network)
259
+ * are retried on the next tick. Nothing here throws out of the timer, and no
260
+ * token or key is ever logged or placed on an event.
261
+ */
262
+ export class GitHubInstallationsManager {
263
+ private readonly opts: GitHubInstallationsManagerOptions;
264
+ private readonly fetchFn: FetchFn;
265
+ private readonly clock: Clock;
266
+ private readonly setIntervalFn: typeof setInterval;
267
+ private readonly clearIntervalFn: typeof clearInterval;
268
+ private readonly tracked = new Map<number, Tracked>();
269
+ private defaultId: number | null = null;
270
+ private timer: ReturnType<typeof setInterval> | null = null;
271
+ private refreshing = false;
272
+ private lastSnapshot = "";
273
+
274
+ constructor(opts: GitHubInstallationsManagerOptions) {
275
+ this.opts = opts;
276
+ this.fetchFn = opts.fetchFn ?? fetch;
277
+ this.clock = opts.clock ?? SystemClock();
278
+ this.setIntervalFn = opts.setIntervalFn ?? setInterval;
279
+ this.clearIntervalFn = opts.clearIntervalFn ?? clearInterval;
280
+ }
281
+
282
+ getState(): InstallationsSnapshot {
283
+ return {
284
+ installations: [...this.tracked.values()]
285
+ .map((t) => ({ ...t.state }))
286
+ .sort((a, b) => a.id - b.id),
287
+ defaultId: this.defaultId,
288
+ };
289
+ }
290
+
291
+ /** Returns a fresh installation token; throws if the installation is unknown or broken. */
292
+ async getToken(installationId: number): Promise<string> {
293
+ const t = this.tracked.get(installationId);
294
+ if (!t) throw new Error(`unknown installation ${installationId}`);
295
+ if (t.state.health === "broken") {
296
+ throw new Error(`installation ${installationId} is broken`);
297
+ }
298
+ return t.tokens.getToken();
299
+ }
300
+
301
+ /** Starts the single refresh timer. Idempotent. */
302
+ start(): void {
303
+ if (this.timer !== null) return;
304
+ this.timer = this.setIntervalFn(() => {
305
+ void this.refresh();
306
+ }, this.opts.refreshIntervalMs ?? MANAGER_REFRESH_INTERVAL_MS);
307
+ }
308
+
309
+ stop(): void {
310
+ if (this.timer !== null) {
311
+ this.clearIntervalFn(this.timer);
312
+ this.timer = null;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Re-runs discovery and selection. `scopeOwners` is the set of repo owners
318
+ * the agent is scoped to, or null while scope is not yet synced. On a
319
+ * discovery failure the previous installation list is kept untouched.
320
+ */
321
+ async reconcile(scopeOwners: string[] | null): Promise<void> {
322
+ try {
323
+ const jwt = (await this.opts.auth({ type: "app" })).token;
324
+ const result = await discoverInstallations(jwt, this.fetchFn);
325
+ if (!result.ok) {
326
+ this.emit({ type: "discovery_failed" });
327
+ return;
328
+ }
329
+ const selection = selectInstallations({
330
+ discovered: result.installations,
331
+ scopedRepos: (scopeOwners ?? []).map((o) => `${o}/-`),
332
+ scopeSynced: scopeOwners !== null,
333
+ pinnedId: this.opts.pinnedId,
334
+ });
335
+ const selectedIds = new Set(selection.installations.map((i) => i.id));
336
+
337
+ for (const id of [...this.tracked.keys()]) {
338
+ if (!selectedIds.has(id)) {
339
+ this.tracked.delete(id);
340
+ this.emit({ type: "removed", installationId: id });
341
+ }
342
+ }
343
+ const suspendedById = new Map(
344
+ result.installations.map((d) => [d.id, d.suspendedAt !== null]),
345
+ );
346
+ for (const sel of selection.installations) {
347
+ const suspended = sel.suspended || suspendedById.get(sel.id) === true;
348
+ const existing = this.tracked.get(sel.id);
349
+ const state: InstallationState = {
350
+ id: sel.id,
351
+ owner: sel.owner,
352
+ pinned: sel.pinned,
353
+ // A mint rejection or lifted suspension is retried after a reconcile.
354
+ health: suspended ? "broken" : (existing?.state.health ?? "unknown"),
355
+ reason: suspended ? "suspended" : (existing?.state.reason ?? null),
356
+ };
357
+ if (
358
+ !suspended &&
359
+ (state.reason === "mint_rejected" || state.reason === "suspended")
360
+ ) {
361
+ state.health = "unknown";
362
+ state.reason = null;
363
+ }
364
+ if (existing) {
365
+ existing.state = state;
366
+ } else {
367
+ this.tracked.set(sel.id, {
368
+ state,
369
+ lastToken: null,
370
+ tokens: new GitHubTokenManager({
371
+ auth: this.opts.auth,
372
+ installationId: sel.id,
373
+ clock: this.clock,
374
+ }),
375
+ });
376
+ this.emit({
377
+ type: "discovered",
378
+ installationId: sel.id,
379
+ owner: sel.owner,
380
+ });
381
+ }
382
+ }
383
+ this.defaultId = selection.defaultId;
384
+ this.notifyIfChanged();
385
+ } catch (err) {
386
+ console.error(
387
+ "[github-installations] reconcile failed:",
388
+ err instanceof Error ? err.name : "error",
389
+ );
390
+ this.emit({ type: "discovery_failed" });
391
+ }
392
+ }
393
+
394
+ /** One timer tick: mint/refresh every non-broken installation independently. */
395
+ async refresh(): Promise<void> {
396
+ if (this.refreshing) return;
397
+ this.refreshing = true;
398
+ try {
399
+ await Promise.all(
400
+ [...this.tracked.values()]
401
+ .filter((t) => t.state.health !== "broken")
402
+ .map((t) => this.mintOne(t)),
403
+ );
404
+ this.notifyIfChanged();
405
+ } catch (err) {
406
+ console.error(
407
+ "[github-installations] refresh failed:",
408
+ err instanceof Error ? err.name : "error",
409
+ );
410
+ } finally {
411
+ this.refreshing = false;
412
+ }
413
+ }
414
+
415
+ private async mintOne(t: Tracked): Promise<void> {
416
+ const id = t.state.id;
417
+ try {
418
+ const token = await t.tokens.getToken();
419
+ t.state.health = "ok";
420
+ if (token !== t.lastToken) {
421
+ t.lastToken = token;
422
+ this.emit({ type: "minted", installationId: id });
423
+ }
424
+ } catch (err) {
425
+ const status = statusOf(err);
426
+ const broken = status !== null && BROKEN_STATUSES.has(status);
427
+ if (broken) {
428
+ t.state.health = "broken";
429
+ t.state.reason = "mint_rejected";
430
+ t.lastToken = null;
431
+ }
432
+ this.emit({
433
+ type: "mint_failed",
434
+ installationId: id,
435
+ statusClass: failureClass(status),
436
+ status,
437
+ broken,
438
+ });
439
+ }
440
+ }
441
+
442
+ private emit(event: InstallationEvent): void {
443
+ console.log(`[github-installations] ${JSON.stringify(event)}`);
444
+ try {
445
+ this.opts.onEvent?.(event);
446
+ } catch {
447
+ // a faulty listener must never break the refresh loop
448
+ }
449
+ }
450
+
451
+ private notifyIfChanged(): void {
452
+ const snapshot = this.getState();
453
+ const key = JSON.stringify(snapshot);
454
+ if (key === this.lastSnapshot) return;
455
+ this.lastSnapshot = key;
456
+ try {
457
+ this.opts.onChange?.(snapshot);
458
+ } catch {
459
+ // a faulty listener must never break the refresh loop
460
+ }
461
+ }
462
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ type DiscoveredInstallation,
4
+ discoverInstallations,
5
+ selectInstallations,
6
+ } from "./github-installations.ts";
7
+
8
+ const inst = (
9
+ id: number,
10
+ owner: string,
11
+ suspendedAt: string | null = null,
12
+ ): DiscoveredInstallation => ({ id, owner, suspendedAt });
13
+
14
+ describe("selectInstallations", () => {
15
+ const discovered = [inst(30, "Acme"), inst(10, "beta"), inst(20, "stranger")];
16
+
17
+ it("ignores installations whose owner is neither in scope nor pinned (case-insensitive)", () => {
18
+ const r = selectInstallations({
19
+ discovered,
20
+ scopedRepos: ["acme/web", "BETA/api"],
21
+ scopeSynced: true,
22
+ });
23
+ expect(r.installations.map((i) => i.id)).toEqual([10, 30]);
24
+ });
25
+
26
+ it("defaults to the lowest id when unpinned", () => {
27
+ const r = selectInstallations({
28
+ discovered,
29
+ scopedRepos: ["acme/web", "beta/api"],
30
+ scopeSynced: true,
31
+ });
32
+ expect(r.defaultId).toBe(10);
33
+ });
34
+
35
+ it("defaults to the pin and includes it even when out of scope", () => {
36
+ const r = selectInstallations({
37
+ discovered,
38
+ scopedRepos: ["beta/api"],
39
+ scopeSynced: true,
40
+ pinnedId: 20,
41
+ });
42
+ expect(r.defaultId).toBe(20);
43
+ expect(r.installations.map((i) => i.id)).toEqual([10, 20]);
44
+ });
45
+
46
+ it("pinned id wins over a discovered id for the same owner", () => {
47
+ const r = selectInstallations({
48
+ discovered: [inst(5, "acme"), inst(9, "Acme")],
49
+ scopedRepos: ["acme/web"],
50
+ scopeSynced: true,
51
+ pinnedId: 9,
52
+ });
53
+ expect(r.installations).toHaveLength(1);
54
+ expect(r.installations[0]).toMatchObject({ id: 9, pinned: true });
55
+ });
56
+
57
+ it("unsynced scope yields pinned-only, never empty", () => {
58
+ const r = selectInstallations({
59
+ discovered,
60
+ scopedRepos: [],
61
+ scopeSynced: false,
62
+ pinnedId: 20,
63
+ });
64
+ expect(r.installations.map((i) => i.id)).toEqual([20]);
65
+ });
66
+
67
+ it("keeps a pinned id that discovery did not return", () => {
68
+ const r = selectInstallations({
69
+ discovered: [],
70
+ scopedRepos: [],
71
+ scopeSynced: false,
72
+ pinnedId: 77,
73
+ });
74
+ expect(r.installations).toEqual([
75
+ { id: 77, owner: null, suspended: false, pinned: true },
76
+ ]);
77
+ expect(r.defaultId).toBe(77);
78
+ });
79
+
80
+ it("flags suspended installations", () => {
81
+ const r = selectInstallations({
82
+ discovered: [inst(1, "acme", "2026-03-01T00:00:00Z")],
83
+ scopedRepos: ["acme/web"],
84
+ scopeSynced: true,
85
+ });
86
+ expect(r.installations[0].suspended).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe("discoverInstallations", () => {
91
+ it("returns an error result instead of throwing when fetch rejects", async () => {
92
+ const fetchFn = (async () => {
93
+ throw new Error("network down");
94
+ }) as unknown as typeof fetch;
95
+ const r = await discoverInstallations("jwt", fetchFn);
96
+ expect(r).toEqual({ ok: false, error: "network down" });
97
+ });
98
+ });