@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.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for GitHubInstallationsManager with injected auth,
|
|
3
|
+
* fetchFn, Clock and a fake setIntervalFn. No real network or timers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
GitHubInstallationsManager,
|
|
9
|
+
type InstallationEvent,
|
|
10
|
+
type InstallationsSnapshot,
|
|
11
|
+
} from "./github-installations.ts";
|
|
12
|
+
|
|
13
|
+
const SECRET_TOKEN = "ghs_SECRET_TOKEN_VALUE";
|
|
14
|
+
const SECRET_JWT = "eyJ.SECRET.JWT";
|
|
15
|
+
|
|
16
|
+
type Raw = { id: number; owner: string; suspended?: boolean };
|
|
17
|
+
|
|
18
|
+
function makeHarness(opts: { pinnedId?: number } = {}) {
|
|
19
|
+
let installs: Raw[] = [];
|
|
20
|
+
let discoveryStatus = 200;
|
|
21
|
+
const mintBehavior = new Map<number, number | "network">(); // id -> failure
|
|
22
|
+
const mintCalls: number[] = [];
|
|
23
|
+
let mintSeq = 0;
|
|
24
|
+
const events: InstallationEvent[] = [];
|
|
25
|
+
const changes: InstallationsSnapshot[] = [];
|
|
26
|
+
const intervals: { fn: () => void; ms: number; cleared: boolean }[] = [];
|
|
27
|
+
|
|
28
|
+
const auth = async (p?: { type: string; installationId?: number }) => {
|
|
29
|
+
if (!p) throw new Error("no params");
|
|
30
|
+
if (p.type === "app")
|
|
31
|
+
return {
|
|
32
|
+
token: SECRET_JWT,
|
|
33
|
+
expiresAt: "",
|
|
34
|
+
type: "app",
|
|
35
|
+
tokenType: "app",
|
|
36
|
+
};
|
|
37
|
+
const id = p.installationId as number;
|
|
38
|
+
mintCalls.push(id);
|
|
39
|
+
const fail = mintBehavior.get(id);
|
|
40
|
+
if (fail === "network") throw new Error("socket hang up");
|
|
41
|
+
if (fail) throw Object.assign(new Error("mint rejected"), { status: fail });
|
|
42
|
+
return {
|
|
43
|
+
token: `${SECRET_TOKEN}_${id}_${++mintSeq}`,
|
|
44
|
+
expiresAt: new Date(Date.UTC(2030, 0, 1)).toISOString(),
|
|
45
|
+
type: "token",
|
|
46
|
+
tokenType: "installation",
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const fetchFn = (async () =>
|
|
50
|
+
new Response(
|
|
51
|
+
JSON.stringify(
|
|
52
|
+
installs.map((i) => ({
|
|
53
|
+
id: i.id,
|
|
54
|
+
account: { login: i.owner },
|
|
55
|
+
suspended_at: i.suspended ? "2026-01-01T00:00:00Z" : null,
|
|
56
|
+
})),
|
|
57
|
+
),
|
|
58
|
+
{ status: discoveryStatus },
|
|
59
|
+
)) as unknown as typeof fetch;
|
|
60
|
+
const setIntervalFn = ((fn: () => void, ms: number) => {
|
|
61
|
+
const rec = { fn, ms, cleared: false };
|
|
62
|
+
intervals.push(rec);
|
|
63
|
+
return rec as unknown as ReturnType<typeof setInterval>;
|
|
64
|
+
}) as unknown as typeof setInterval;
|
|
65
|
+
const clearIntervalFn = ((h: { cleared: boolean }) => {
|
|
66
|
+
h.cleared = true;
|
|
67
|
+
}) as unknown as typeof clearInterval;
|
|
68
|
+
|
|
69
|
+
const manager = new GitHubInstallationsManager({
|
|
70
|
+
auth,
|
|
71
|
+
fetchFn,
|
|
72
|
+
clock: { now: () => new Date(Date.UTC(2029, 0, 1)) },
|
|
73
|
+
setIntervalFn,
|
|
74
|
+
clearIntervalFn,
|
|
75
|
+
pinnedId: opts.pinnedId,
|
|
76
|
+
onEvent: (e) => events.push(e),
|
|
77
|
+
onChange: (s) => changes.push(s),
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
manager,
|
|
81
|
+
events,
|
|
82
|
+
changes,
|
|
83
|
+
intervals,
|
|
84
|
+
mintCalls,
|
|
85
|
+
mintBehavior,
|
|
86
|
+
setInstalls: (i: Raw[]) => {
|
|
87
|
+
installs = i;
|
|
88
|
+
},
|
|
89
|
+
setDiscoveryStatus: (s: number) => {
|
|
90
|
+
discoveryStatus = s;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("GitHubInstallationsManager", () => {
|
|
96
|
+
let logSpy: ReturnType<typeof spyOn>;
|
|
97
|
+
let errSpy: ReturnType<typeof spyOn>;
|
|
98
|
+
let logged: string[];
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
logged = [];
|
|
101
|
+
logSpy = spyOn(console, "log").mockImplementation((...a) => {
|
|
102
|
+
logged.push(a.map(String).join(" "));
|
|
103
|
+
});
|
|
104
|
+
errSpy = spyOn(console, "error").mockImplementation((...a) => {
|
|
105
|
+
logged.push(a.map(String).join(" "));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
logSpy.mockRestore();
|
|
110
|
+
errSpy.mockRestore();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("creates exactly one interval for N installations", async () => {
|
|
114
|
+
const h = makeHarness();
|
|
115
|
+
h.setInstalls([
|
|
116
|
+
{ id: 1, owner: "a" },
|
|
117
|
+
{ id: 2, owner: "b" },
|
|
118
|
+
{ id: 3, owner: "c" },
|
|
119
|
+
]);
|
|
120
|
+
await h.manager.reconcile(["a", "b", "c"]);
|
|
121
|
+
h.manager.start();
|
|
122
|
+
h.manager.start();
|
|
123
|
+
expect(h.manager.getState().installations).toHaveLength(3);
|
|
124
|
+
expect(h.intervals).toHaveLength(1);
|
|
125
|
+
h.manager.stop();
|
|
126
|
+
expect(h.intervals[0].cleared).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("a broken installation does not affect the others' tokens", async () => {
|
|
130
|
+
const h = makeHarness();
|
|
131
|
+
h.setInstalls([
|
|
132
|
+
{ id: 1, owner: "a" },
|
|
133
|
+
{ id: 2, owner: "b" },
|
|
134
|
+
]);
|
|
135
|
+
h.mintBehavior.set(1, 403);
|
|
136
|
+
await h.manager.reconcile(["a", "b"]);
|
|
137
|
+
await h.manager.refresh();
|
|
138
|
+
const byId = new Map(
|
|
139
|
+
h.manager.getState().installations.map((i) => [i.id, i]),
|
|
140
|
+
);
|
|
141
|
+
expect(byId.get(1)?.health).toBe("broken");
|
|
142
|
+
expect(byId.get(1)?.reason).toBe("mint_rejected");
|
|
143
|
+
expect(byId.get(2)?.health).toBe("ok");
|
|
144
|
+
expect(await h.manager.getToken(2)).toContain("_2_");
|
|
145
|
+
await expect(h.manager.getToken(1)).rejects.toThrow("broken");
|
|
146
|
+
// broken installation is no longer retried on later ticks
|
|
147
|
+
h.mintCalls.length = 0;
|
|
148
|
+
await h.manager.refresh();
|
|
149
|
+
expect(h.mintCalls).not.toContain(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("treats suspended installations as broken without minting", async () => {
|
|
153
|
+
const h = makeHarness();
|
|
154
|
+
h.setInstalls([{ id: 1, owner: "a", suspended: true }]);
|
|
155
|
+
await h.manager.reconcile(["a"]);
|
|
156
|
+
await h.manager.refresh();
|
|
157
|
+
expect(h.manager.getState().installations[0]).toMatchObject({
|
|
158
|
+
health: "broken",
|
|
159
|
+
reason: "suspended",
|
|
160
|
+
});
|
|
161
|
+
expect(h.mintCalls).toHaveLength(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("recovers a suspended installation after it is unsuspended", async () => {
|
|
165
|
+
const h = makeHarness();
|
|
166
|
+
h.setInstalls([{ id: 1, owner: "a", suspended: true }]);
|
|
167
|
+
await h.manager.reconcile(["a"]);
|
|
168
|
+
await h.manager.refresh();
|
|
169
|
+
expect(h.manager.getState().installations[0]).toMatchObject({
|
|
170
|
+
health: "broken",
|
|
171
|
+
reason: "suspended",
|
|
172
|
+
});
|
|
173
|
+
h.setInstalls([{ id: 1, owner: "a" }]);
|
|
174
|
+
await h.manager.reconcile(["a"]);
|
|
175
|
+
await h.manager.refresh();
|
|
176
|
+
expect(h.manager.getState().installations[0]).toMatchObject({
|
|
177
|
+
health: "ok",
|
|
178
|
+
reason: null,
|
|
179
|
+
});
|
|
180
|
+
expect(h.mintCalls).toEqual([1]);
|
|
181
|
+
expect(await h.manager.getToken(1)).toContain("_1_");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("marks 404/422 broken but keeps 5xx and network errors transient", async () => {
|
|
185
|
+
const h = makeHarness();
|
|
186
|
+
h.setInstalls([
|
|
187
|
+
{ id: 1, owner: "a" },
|
|
188
|
+
{ id: 2, owner: "b" },
|
|
189
|
+
{ id: 3, owner: "c" },
|
|
190
|
+
{ id: 4, owner: "d" },
|
|
191
|
+
]);
|
|
192
|
+
h.mintBehavior.set(1, 404);
|
|
193
|
+
h.mintBehavior.set(2, 422);
|
|
194
|
+
h.mintBehavior.set(3, 503);
|
|
195
|
+
h.mintBehavior.set(4, "network");
|
|
196
|
+
await h.manager.reconcile(["a", "b", "c", "d"]);
|
|
197
|
+
await h.manager.refresh();
|
|
198
|
+
const health = Object.fromEntries(
|
|
199
|
+
h.manager.getState().installations.map((i) => [i.id, i.health]),
|
|
200
|
+
);
|
|
201
|
+
expect(health).toEqual({
|
|
202
|
+
1: "broken",
|
|
203
|
+
2: "broken",
|
|
204
|
+
3: "unknown",
|
|
205
|
+
4: "unknown",
|
|
206
|
+
});
|
|
207
|
+
// transient ones retry, and recover when the error clears
|
|
208
|
+
h.mintBehavior.delete(3);
|
|
209
|
+
await h.manager.refresh();
|
|
210
|
+
expect(
|
|
211
|
+
h.manager.getState().installations.find((i) => i.id === 3)?.health,
|
|
212
|
+
).toBe("ok");
|
|
213
|
+
const failed = h.events.filter((e) => e.type === "mint_failed");
|
|
214
|
+
expect(
|
|
215
|
+
failed
|
|
216
|
+
.slice(0, 4)
|
|
217
|
+
.map((e) => (e.type === "mint_failed" ? e.statusClass : null)),
|
|
218
|
+
).toEqual(["4xx", "4xx", "5xx", "network"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("emits minted, mint_failed, discovered and removed events", async () => {
|
|
222
|
+
const h = makeHarness();
|
|
223
|
+
h.setInstalls([
|
|
224
|
+
{ id: 1, owner: "a" },
|
|
225
|
+
{ id: 2, owner: "b" },
|
|
226
|
+
]);
|
|
227
|
+
h.mintBehavior.set(2, 500);
|
|
228
|
+
await h.manager.reconcile(["a", "b"]);
|
|
229
|
+
await h.manager.refresh();
|
|
230
|
+
h.setInstalls([{ id: 2, owner: "b" }]);
|
|
231
|
+
await h.manager.reconcile(["a", "b"]);
|
|
232
|
+
const types = new Set(h.events.map((e) => e.type));
|
|
233
|
+
expect(types).toEqual(
|
|
234
|
+
new Set(["discovered", "minted", "mint_failed", "removed"]),
|
|
235
|
+
);
|
|
236
|
+
expect(h.events).toContainEqual({ type: "removed", installationId: 1 });
|
|
237
|
+
expect(h.events).toContainEqual({ type: "minted", installationId: 1 });
|
|
238
|
+
expect(h.manager.getState().installations.map((i) => i.id)).toEqual([2]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("minted is emitted only when a new token is minted", async () => {
|
|
242
|
+
const h = makeHarness();
|
|
243
|
+
h.setInstalls([{ id: 1, owner: "a" }]);
|
|
244
|
+
await h.manager.reconcile(["a"]);
|
|
245
|
+
await h.manager.refresh();
|
|
246
|
+
await h.manager.refresh();
|
|
247
|
+
expect(h.events.filter((e) => e.type === "minted")).toHaveLength(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("a discovery failure leaves the previous installation list in place", async () => {
|
|
251
|
+
const h = makeHarness();
|
|
252
|
+
h.setInstalls([{ id: 1, owner: "a" }]);
|
|
253
|
+
await h.manager.reconcile(["a"]);
|
|
254
|
+
h.setDiscoveryStatus(500);
|
|
255
|
+
await h.manager.reconcile(["a"]);
|
|
256
|
+
expect(h.manager.getState().installations.map((i) => i.id)).toEqual([1]);
|
|
257
|
+
expect(h.events).toContainEqual({ type: "discovery_failed" });
|
|
258
|
+
expect(h.events.some((e) => e.type === "removed")).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("calls onChange only when state changes", async () => {
|
|
262
|
+
const h = makeHarness();
|
|
263
|
+
h.setInstalls([{ id: 1, owner: "a" }]);
|
|
264
|
+
await h.manager.reconcile(["a"]);
|
|
265
|
+
await h.manager.reconcile(["a"]);
|
|
266
|
+
expect(h.changes).toHaveLength(1);
|
|
267
|
+
await h.manager.refresh();
|
|
268
|
+
expect(h.changes).toHaveLength(2);
|
|
269
|
+
expect(h.changes[1].installations[0].health).toBe("ok");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("never throws out of the timer, even when listeners or auth throw", async () => {
|
|
273
|
+
const h = makeHarness();
|
|
274
|
+
h.setInstalls([{ id: 1, owner: "a" }]);
|
|
275
|
+
await h.manager.reconcile(["a"]);
|
|
276
|
+
h.manager.start();
|
|
277
|
+
h.mintBehavior.set(1, 500);
|
|
278
|
+
expect(() => h.intervals[0].fn()).not.toThrow();
|
|
279
|
+
await h.manager.refresh();
|
|
280
|
+
const bad = new GitHubInstallationsManager({
|
|
281
|
+
auth: async () => {
|
|
282
|
+
throw new Error("jwt signing failed");
|
|
283
|
+
},
|
|
284
|
+
onEvent: () => {
|
|
285
|
+
throw new Error("listener boom");
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
await expect(bad.reconcile(["a"])).resolves.toBeUndefined();
|
|
289
|
+
await expect(bad.refresh()).resolves.toBeUndefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("uses a pinned id before scope is synced", async () => {
|
|
293
|
+
const h = makeHarness({ pinnedId: 7 });
|
|
294
|
+
h.setInstalls([
|
|
295
|
+
{ id: 7, owner: "a" },
|
|
296
|
+
{ id: 8, owner: "b" },
|
|
297
|
+
]);
|
|
298
|
+
await h.manager.reconcile(null);
|
|
299
|
+
expect(h.manager.getState()).toMatchObject({ defaultId: 7 });
|
|
300
|
+
expect(h.manager.getState().installations.map((i) => i.id)).toEqual([7]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("never exposes token or key material in logs or events", async () => {
|
|
304
|
+
const h = makeHarness();
|
|
305
|
+
h.setInstalls([
|
|
306
|
+
{ id: 1, owner: "a" },
|
|
307
|
+
{ id: 2, owner: "b" },
|
|
308
|
+
]);
|
|
309
|
+
h.mintBehavior.set(2, 403);
|
|
310
|
+
await h.manager.reconcile(["a", "b"]);
|
|
311
|
+
await h.manager.refresh();
|
|
312
|
+
h.setDiscoveryStatus(500);
|
|
313
|
+
await h.manager.reconcile(["a", "b"]);
|
|
314
|
+
const blob = JSON.stringify([
|
|
315
|
+
h.events,
|
|
316
|
+
h.changes,
|
|
317
|
+
h.manager.getState(),
|
|
318
|
+
logged,
|
|
319
|
+
]);
|
|
320
|
+
expect(blob).not.toContain("SECRET");
|
|
321
|
+
expect(blob).not.toContain("ghs_");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration: GitHubInstallationsManager -> HttpGitHubInstallationsReporter.
|
|
3
|
+
* Real manager with injected auth/fetch/clock, reporter wired via onChange.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
7
|
+
import { GitHubInstallationsManager } from "./github-installations.ts";
|
|
8
|
+
import { HttpGitHubInstallationsReporter } from "./github-installations-reporter.ts";
|
|
9
|
+
|
|
10
|
+
const SECRET_TOKEN = "ghs_SECRET_TOKEN_VALUE";
|
|
11
|
+
const SECRET_JWT = "eyJ.SECRET.JWT";
|
|
12
|
+
|
|
13
|
+
function setup(opts: { reporterFetch?: typeof fetch } = {}) {
|
|
14
|
+
let mintFail: number | null = null;
|
|
15
|
+
let seq = 0;
|
|
16
|
+
const bodies: string[] = [];
|
|
17
|
+
const auth = async (p?: { type: string }) =>
|
|
18
|
+
p?.type === "app"
|
|
19
|
+
? { token: SECRET_JWT, expiresAt: "", type: "app", tokenType: "app" }
|
|
20
|
+
: mintFail
|
|
21
|
+
? Promise.reject(
|
|
22
|
+
Object.assign(new Error("rejected"), { status: mintFail }),
|
|
23
|
+
)
|
|
24
|
+
: {
|
|
25
|
+
token: `${SECRET_TOKEN}_${++seq}`,
|
|
26
|
+
expiresAt: new Date(Date.UTC(2030, 0, 1)).toISOString(),
|
|
27
|
+
type: "token",
|
|
28
|
+
tokenType: "installation",
|
|
29
|
+
};
|
|
30
|
+
const discovery = (async () =>
|
|
31
|
+
new Response(
|
|
32
|
+
JSON.stringify([
|
|
33
|
+
{ id: 1, account: { login: "acme" }, suspended_at: null },
|
|
34
|
+
]),
|
|
35
|
+
)) as unknown as typeof fetch;
|
|
36
|
+
const reporterFetch =
|
|
37
|
+
opts.reporterFetch ??
|
|
38
|
+
((async (_u: RequestInfo | URL, init?: RequestInit) => {
|
|
39
|
+
bodies.push(String(init?.body));
|
|
40
|
+
return new Response("{}", { status: 200 });
|
|
41
|
+
}) as unknown as typeof fetch);
|
|
42
|
+
const clock = { now: () => new Date(Date.UTC(2029, 0, 1)) };
|
|
43
|
+
const reporter = new HttpGitHubInstallationsReporter({
|
|
44
|
+
apiUrl: "http://api",
|
|
45
|
+
agentId: "a1",
|
|
46
|
+
apiKey: "KEY",
|
|
47
|
+
fetchFn: reporterFetch,
|
|
48
|
+
clock,
|
|
49
|
+
});
|
|
50
|
+
const manager = new GitHubInstallationsManager({
|
|
51
|
+
auth: auth as never,
|
|
52
|
+
fetchFn: discovery,
|
|
53
|
+
clock,
|
|
54
|
+
onChange: (s) => void reporter.report(s),
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
manager,
|
|
58
|
+
bodies,
|
|
59
|
+
setMintFail: (s: number | null) => {
|
|
60
|
+
mintFail = s;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("manager -> reporter", () => {
|
|
66
|
+
let spies: ReturnType<typeof spyOn>[];
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
spies = [
|
|
69
|
+
spyOn(console, "log").mockImplementation(() => {}),
|
|
70
|
+
spyOn(console, "error").mockImplementation(() => {}),
|
|
71
|
+
spyOn(console, "warn").mockImplementation(() => {}),
|
|
72
|
+
];
|
|
73
|
+
});
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
for (const s of spies) s.mockRestore();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("mint failure marks broken without discovery; later success clears it", async () => {
|
|
79
|
+
const h = setup();
|
|
80
|
+
h.setMintFail(403);
|
|
81
|
+
await h.manager.reconcile(["acme"]);
|
|
82
|
+
await h.manager.refresh();
|
|
83
|
+
const states = () =>
|
|
84
|
+
h.bodies.map((b) => JSON.parse(b).installations[0].state as string);
|
|
85
|
+
expect(states().at(-1)).toBe("broken");
|
|
86
|
+
h.setMintFail(null);
|
|
87
|
+
await h.manager.reconcile(["acme"]); // clears the rejection for retry
|
|
88
|
+
await h.manager.refresh();
|
|
89
|
+
expect(states().at(-1)).toBe("ok");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("refresh with no state change posts nothing extra", async () => {
|
|
93
|
+
const h = setup();
|
|
94
|
+
await h.manager.reconcile(["acme"]);
|
|
95
|
+
await h.manager.refresh();
|
|
96
|
+
const n = h.bodies.length;
|
|
97
|
+
await h.manager.refresh();
|
|
98
|
+
await h.manager.refresh();
|
|
99
|
+
expect(h.bodies).toHaveLength(n);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("a reporter that rejects or times out does not change manager behavior", async () => {
|
|
103
|
+
const h = setup({
|
|
104
|
+
reporterFetch: (async () => {
|
|
105
|
+
throw new Error("timeout");
|
|
106
|
+
}) as unknown as typeof fetch,
|
|
107
|
+
});
|
|
108
|
+
await h.manager.reconcile(["acme"]);
|
|
109
|
+
await h.manager.refresh();
|
|
110
|
+
expect(h.manager.getState().installations[0].health).toBe("ok");
|
|
111
|
+
expect(await h.manager.getToken(1)).toContain(SECRET_TOKEN);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("no token or key material in any posted payload", async () => {
|
|
115
|
+
const h = setup();
|
|
116
|
+
await h.manager.reconcile(["acme"]);
|
|
117
|
+
await h.manager.refresh();
|
|
118
|
+
expect(h.bodies.length).toBeGreaterThan(0);
|
|
119
|
+
for (const b of h.bodies) {
|
|
120
|
+
expect(b).not.toContain("ghs_");
|
|
121
|
+
expect(b).not.toContain("SECRET");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHubInstallationsReporter — reports the installations manager's state to
|
|
3
|
+
* the admin API: PUT /agents/:agentId/github-installations (replace-all).
|
|
4
|
+
*
|
|
5
|
+
* Posts a full snapshot when the state signature changes (discovered,
|
|
6
|
+
* removed, ok <-> broken) and on a heartbeat once HEARTBEAT_MS has passed
|
|
7
|
+
* since the last post; a report with no change inside the heartbeat window
|
|
8
|
+
* posts nothing. Same fire-and-forget contract as WorkQueueReporter: never
|
|
9
|
+
* throws, swallows non-2xx and thrown errors with console.warn, injectable
|
|
10
|
+
* `fetchFn` and `clock`. The payload is built from a fixed allowlist of
|
|
11
|
+
* fields with constant error strings — no token, key, or raw response body.
|
|
12
|
+
*
|
|
13
|
+
* HttpGitHubInstallationsReporter: production implementation.
|
|
14
|
+
* NoopGitHubInstallationsReporter: testing / default when not configured.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
18
|
+
import type {
|
|
19
|
+
InstallationState,
|
|
20
|
+
InstallationsSnapshot,
|
|
21
|
+
} from "./github-installations.ts";
|
|
22
|
+
|
|
23
|
+
export const INSTALLATIONS_HEARTBEAT_MS = 30 * 60 * 1000;
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
export interface GitHubInstallationsReporter {
|
|
27
|
+
/** Fire-and-forget: never throws, never blocks the caller on a failed PUT. */
|
|
28
|
+
report(snapshot: InstallationsSnapshot): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ReportedInstallation {
|
|
32
|
+
owner: string;
|
|
33
|
+
installationId: number;
|
|
34
|
+
state: InstallationState["health"];
|
|
35
|
+
lastError: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const REASON_TEXT: Record<NonNullable<InstallationState["reason"]>, string> = {
|
|
39
|
+
suspended: "installation suspended",
|
|
40
|
+
mint_rejected: "installation token mint rejected",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function toReported(i: InstallationState): ReportedInstallation {
|
|
44
|
+
return {
|
|
45
|
+
owner: i.owner ?? "unknown",
|
|
46
|
+
installationId: i.id,
|
|
47
|
+
state: i.health,
|
|
48
|
+
lastError: i.reason ? REASON_TEXT[i.reason] : null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface HttpGitHubInstallationsReporterOptions {
|
|
53
|
+
apiUrl: string;
|
|
54
|
+
agentId: string;
|
|
55
|
+
apiKey: string;
|
|
56
|
+
fetchFn?: typeof fetch;
|
|
57
|
+
clock?: Clock;
|
|
58
|
+
heartbeatMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class HttpGitHubInstallationsReporter
|
|
62
|
+
implements GitHubInstallationsReporter
|
|
63
|
+
{
|
|
64
|
+
private readonly fetchFn: typeof fetch;
|
|
65
|
+
private readonly clock: Clock;
|
|
66
|
+
private readonly heartbeatMs: number;
|
|
67
|
+
private lastSignature: string | null = null;
|
|
68
|
+
private lastPostedAt = 0;
|
|
69
|
+
|
|
70
|
+
constructor(private opts: HttpGitHubInstallationsReporterOptions) {
|
|
71
|
+
this.fetchFn = opts.fetchFn ?? fetch;
|
|
72
|
+
this.clock = opts.clock ?? SystemClock();
|
|
73
|
+
this.heartbeatMs = opts.heartbeatMs ?? INSTALLATIONS_HEARTBEAT_MS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async report(snapshot: InstallationsSnapshot): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
const installations = snapshot.installations.map(toReported);
|
|
79
|
+
const signature = JSON.stringify(installations);
|
|
80
|
+
const nowMs = this.clock.now().getTime();
|
|
81
|
+
const changed = signature !== this.lastSignature;
|
|
82
|
+
if (!changed && nowMs - this.lastPostedAt < this.heartbeatMs) return;
|
|
83
|
+
// Claim the slot before awaiting so overlapping reports don't double-post.
|
|
84
|
+
this.lastSignature = signature;
|
|
85
|
+
this.lastPostedAt = nowMs;
|
|
86
|
+
await this.put(installations, new Date(nowMs).toISOString());
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.warn(
|
|
89
|
+
`[github-installations-reporter] report failed: ${String(err)} — swallowing`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async put(
|
|
95
|
+
installations: ReportedInstallation[],
|
|
96
|
+
reportedAt: string,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const { apiUrl, agentId, apiKey } = this.opts;
|
|
99
|
+
const url = `${apiUrl}/agents/${agentId}/github-installations`;
|
|
100
|
+
try {
|
|
101
|
+
const res = await this.fetchFn(url, {
|
|
102
|
+
method: "PUT",
|
|
103
|
+
headers: {
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
Authorization: `Bearer ${apiKey}`,
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify({ reportedAt, installations }),
|
|
108
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
this.lastSignature = null; // retry on the next report
|
|
112
|
+
console.warn(
|
|
113
|
+
`[github-installations-reporter] PUT ${url} returned ${res.status} — swallowing`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
this.lastSignature = null;
|
|
118
|
+
console.warn(
|
|
119
|
+
`[github-installations-reporter] PUT ${url} failed: ${String(err)} — swallowing`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class NoopGitHubInstallationsReporter
|
|
126
|
+
implements GitHubInstallationsReporter
|
|
127
|
+
{
|
|
128
|
+
async report(_snapshot: InstallationsSnapshot): Promise<void> {
|
|
129
|
+
// intentional no-op
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Drives the heartbeat: the manager only calls onChange on a state change, so
|
|
135
|
+
* a quiet period needs its own tick to re-report the current state.
|
|
136
|
+
*/
|
|
137
|
+
export function startInstallationsHeartbeat(
|
|
138
|
+
reporter: GitHubInstallationsReporter,
|
|
139
|
+
getState: () => InstallationsSnapshot,
|
|
140
|
+
opts: { setIntervalFn?: typeof setInterval; tickMs?: number } = {},
|
|
141
|
+
): () => void {
|
|
142
|
+
const setIntervalFn = opts.setIntervalFn ?? setInterval;
|
|
143
|
+
const timer = setIntervalFn(
|
|
144
|
+
() => {
|
|
145
|
+
void reporter.report(getState());
|
|
146
|
+
},
|
|
147
|
+
opts.tickMs ?? 5 * 60 * 1000,
|
|
148
|
+
);
|
|
149
|
+
return () => clearInterval(timer);
|
|
150
|
+
}
|