@app-vitals/agent-runtime 0.3.0 → 0.5.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/boot.ts +72 -1
- package/cron-handler.ts +10 -1
- package/cron-handler.unit.test.ts +28 -0
- package/cron-run-now.ts +116 -0
- package/cron-run-now.unit.test.ts +156 -0
- package/cutover-validate.integration.test.ts +2 -0
- package/github-installations-reporter.ts +21 -3
- package/github-installations-reporter.unit.test.ts +51 -1
- package/package.json +3 -3
- package/repo-access-probe.ts +193 -0
- package/repo-access-probe.unit.test.ts +211 -0
- package/shipwright-runtime-client.integration.test.ts +2 -0
package/boot.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
planCronSchedule,
|
|
32
32
|
runPeriodicPasses,
|
|
33
33
|
} from "./agent-extension.ts";
|
|
34
|
+
import { agentFeaturesRef } from "./agent-features-ref.ts";
|
|
34
35
|
import { agentReposRef } from "./agent-repos-ref.ts";
|
|
35
36
|
import {
|
|
36
37
|
agentSlackMembershipRef,
|
|
@@ -50,7 +51,6 @@ import {
|
|
|
50
51
|
setLiveClaudeConfig,
|
|
51
52
|
} from "./claude.ts";
|
|
52
53
|
import { bunExec, detectClaudeCodeVersion } from "./claude-version.ts";
|
|
53
|
-
import { agentFeaturesRef } from "./agent-features-ref.ts";
|
|
54
54
|
import { cleanupAfterDaysRef } from "./cleanup-after-days-ref.ts";
|
|
55
55
|
import { SystemClock } from "./clock.ts";
|
|
56
56
|
import { createConfig } from "./config.ts";
|
|
@@ -58,6 +58,7 @@ import { createContextStampReader } from "./context-stamp.ts";
|
|
|
58
58
|
import { reportCronFailure } from "./cron-failure-reporter.ts";
|
|
59
59
|
import type { CronHandlerDeps } from "./cron-handler.ts";
|
|
60
60
|
import { handleCronRequest } from "./cron-handler.ts";
|
|
61
|
+
import { createRunNowHonourer } from "./cron-run-now.ts";
|
|
61
62
|
import {
|
|
62
63
|
HttpCronRunReporter,
|
|
63
64
|
NoopCronRunReporter,
|
|
@@ -84,6 +85,10 @@ import {
|
|
|
84
85
|
import { HttpChatServiceClient } from "./http-chat-service-client.ts";
|
|
85
86
|
import { buildLogPrefix } from "./log-prefix.ts";
|
|
86
87
|
import { validatePiperVoice } from "./piper-voice.ts";
|
|
88
|
+
import {
|
|
89
|
+
createRepoAccessProbe,
|
|
90
|
+
createRepoAccessTokenFor,
|
|
91
|
+
} from "./repo-access-probe.ts";
|
|
87
92
|
import {
|
|
88
93
|
createFileSessionStore,
|
|
89
94
|
startSessionPruner,
|
|
@@ -223,6 +228,17 @@ export async function startAgent(
|
|
|
223
228
|
apiUrl: config.shipwright.apiUrl,
|
|
224
229
|
agentId: config.shipwright.agentId,
|
|
225
230
|
apiKey: config.shipwright.apiKey,
|
|
231
|
+
repoAccessProbe: createRepoAccessProbe({
|
|
232
|
+
fetch,
|
|
233
|
+
clock: SystemClock(),
|
|
234
|
+
log: (message) => console.log(message),
|
|
235
|
+
getRepos: () => agentReposRef.get(),
|
|
236
|
+
isEnabled: () => agentFeaturesRef.get()?.repoAccessCheck === true,
|
|
237
|
+
tokenFor: createRepoAccessTokenFor(
|
|
238
|
+
() => githubInstallationsManagerRef.get(),
|
|
239
|
+
process.env,
|
|
240
|
+
),
|
|
241
|
+
}),
|
|
226
242
|
})
|
|
227
243
|
: new NoopGitHubInstallationsReporter();
|
|
228
244
|
|
|
@@ -684,6 +700,54 @@ export async function startAgent(
|
|
|
684
700
|
const cronTasks = new Map<string, ReturnType<typeof nodeCron.schedule>>();
|
|
685
701
|
|
|
686
702
|
if (runtimeClient && agentId) {
|
|
703
|
+
// Run-now requests (COS-42.1): honoured at most once each, in memory.
|
|
704
|
+
// Dispatch kind per job from the latest sync, so run-now takes the same
|
|
705
|
+
// path a scheduled tick would (loop-classified crons go through dispatchLoop).
|
|
706
|
+
let dispatchKinds = new Map<string, "loop" | "generic">();
|
|
707
|
+
const honourRunNow = createRunNowHonourer({
|
|
708
|
+
clock: SystemClock(),
|
|
709
|
+
dispatch: async (req) => {
|
|
710
|
+
if (dispatchKinds.get(req.jobId) === "loop") {
|
|
711
|
+
if (!extension.dispatchLoop) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
`cron job ${req.jobId} classified as loop but the agent extension has no dispatchLoop`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
// dispatchLoop creates no run-now run of its own, so record one here:
|
|
717
|
+
// creating it clears the stamp and flips the audit row to honoured.
|
|
718
|
+
const reporter = cronRunReporter ?? new NoopCronRunReporter();
|
|
719
|
+
const startedAt = SystemClock().now();
|
|
720
|
+
const runId = await reporter.createRun(
|
|
721
|
+
req.jobId,
|
|
722
|
+
startedAt,
|
|
723
|
+
undefined,
|
|
724
|
+
undefined,
|
|
725
|
+
undefined,
|
|
726
|
+
req.commandVariant,
|
|
727
|
+
);
|
|
728
|
+
let outcome: "completed" | "failed" = "completed";
|
|
729
|
+
try {
|
|
730
|
+
await extension.dispatchLoop(req.jobId);
|
|
731
|
+
} catch (err) {
|
|
732
|
+
outcome = "failed";
|
|
733
|
+
throw err;
|
|
734
|
+
} finally {
|
|
735
|
+
await reporter
|
|
736
|
+
.completeRun(req.jobId, runId, SystemClock().now(), outcome)
|
|
737
|
+
.catch(() => {});
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
await handleCronRequest(req, cronDeps);
|
|
742
|
+
},
|
|
743
|
+
onError: (jobId, err) =>
|
|
744
|
+
reportCronFailure(jobId, err, {
|
|
745
|
+
cronRunReporter: cronRunReporter ?? new NoopCronRunReporter(),
|
|
746
|
+
sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
|
|
747
|
+
clock: SystemClock(),
|
|
748
|
+
}),
|
|
749
|
+
});
|
|
750
|
+
|
|
687
751
|
async function syncCrons() {
|
|
688
752
|
if (!runtimeClient || !agentId) return;
|
|
689
753
|
let jobs: Awaited<ReturnType<typeof runtimeClient.listAgentCronJobs>>;
|
|
@@ -705,6 +769,13 @@ export async function startAgent(
|
|
|
705
769
|
// the coding extension's loop-cron-classifier.ts for the full decision.
|
|
706
770
|
const scheduled = planCronSchedule(extension, jobs);
|
|
707
771
|
const desired = new Map(scheduled.map((entry) => [entry.job.id, entry]));
|
|
772
|
+
dispatchKinds = new Map(
|
|
773
|
+
scheduled.map((entry) => [entry.job.id, entry.dispatch]),
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
// Fire-and-forget: a run can take minutes and must not hold up the sync.
|
|
777
|
+
// Only scheduled (enabled) jobs: a manually disabled cron is not run.
|
|
778
|
+
void honourRunNow(jobs.filter((job) => desired.has(job.id)));
|
|
708
779
|
|
|
709
780
|
// Cancel removed/disabled jobs
|
|
710
781
|
for (const [id, task] of cronTasks) {
|
package/cron-handler.ts
CHANGED
|
@@ -162,6 +162,8 @@ interface CronRequest {
|
|
|
162
162
|
user?: string;
|
|
163
163
|
silent?: boolean;
|
|
164
164
|
preCheck?: string;
|
|
165
|
+
/** Run-attribution tag, e.g. "run-now" (COS-42.1); passed to createRun. */
|
|
166
|
+
commandVariant?: string;
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
export interface CronHandlerDeps {
|
|
@@ -266,7 +268,14 @@ export async function handleCronRequest(
|
|
|
266
268
|
|
|
267
269
|
// CREATE run at start (before preCheck)
|
|
268
270
|
const runId = cronRunReporter
|
|
269
|
-
? await cronRunReporter.createRun(
|
|
271
|
+
? await cronRunReporter.createRun(
|
|
272
|
+
jobId,
|
|
273
|
+
startedAt,
|
|
274
|
+
undefined,
|
|
275
|
+
undefined,
|
|
276
|
+
undefined,
|
|
277
|
+
req.commandVariant,
|
|
278
|
+
)
|
|
270
279
|
: null;
|
|
271
280
|
|
|
272
281
|
// Best-effort redacted transcript upload (COS-43.1). Runs only after the
|
|
@@ -1176,6 +1176,34 @@ describe("handleCronRequest — telemetry forwarding", () => {
|
|
|
1176
1176
|
});
|
|
1177
1177
|
});
|
|
1178
1178
|
|
|
1179
|
+
describe("handleCronRequest — commandVariant (COS-42.1)", () => {
|
|
1180
|
+
test("a run-now request creates its run with commandVariant run-now", async () => {
|
|
1181
|
+
const created: unknown[][] = [];
|
|
1182
|
+
const reporter: CronRunReporter = {
|
|
1183
|
+
async createRun(...args: unknown[]) {
|
|
1184
|
+
created.push(args);
|
|
1185
|
+
return "run-now-1";
|
|
1186
|
+
},
|
|
1187
|
+
async completeRun() {},
|
|
1188
|
+
async skipRun() {},
|
|
1189
|
+
async recordProgress() {},
|
|
1190
|
+
async recordSessionId() {},
|
|
1191
|
+
};
|
|
1192
|
+
await handleCronRequest(
|
|
1193
|
+
{
|
|
1194
|
+
jobId: "j-now",
|
|
1195
|
+
prompt: "hello",
|
|
1196
|
+
silent: true,
|
|
1197
|
+
commandVariant: "run-now",
|
|
1198
|
+
},
|
|
1199
|
+
{ ...deps, cronRunReporter: reporter },
|
|
1200
|
+
);
|
|
1201
|
+
expect(created).toHaveLength(1);
|
|
1202
|
+
expect(created[0]?.[0]).toBe("j-now");
|
|
1203
|
+
expect(created[0]?.[5]).toBe("run-now");
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1179
1207
|
// ─── transcript upload hook (COS-43.1) ───────────────────────────────────────
|
|
1180
1208
|
|
|
1181
1209
|
describe("handleCronRequest — transcript upload (COS-43.1)", () => {
|
package/cron-run-now.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/cron-run-now.ts
|
|
3
|
+
*
|
|
4
|
+
* Honours cron run-now requests (COS-42.1). The admin stamps
|
|
5
|
+
* `runRequestedAt` on a stored cron job; on each cron sync tick the runtime
|
|
6
|
+
* runs the job once when the stamp is strictly newer than the last one it
|
|
7
|
+
* honoured (held in memory) and at most 15 minutes old. An older stamp is
|
|
8
|
+
* logged as expired and never run. The request is dispatched with the job's
|
|
9
|
+
* own stored fields, never a prompt from anywhere else, and tagged
|
|
10
|
+
* commandVariant "run-now" so run creation clears the stamp admin-side.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Clock } from "./clock.ts";
|
|
14
|
+
|
|
15
|
+
/** A request older than this is expired and never run. */
|
|
16
|
+
export const RUN_NOW_FRESH_MS = 15 * 60_000;
|
|
17
|
+
|
|
18
|
+
/** The stored cron job fields the honourer reads. */
|
|
19
|
+
export interface RunNowJob {
|
|
20
|
+
id: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
channel?: string | null;
|
|
23
|
+
user?: string | null;
|
|
24
|
+
silent?: boolean;
|
|
25
|
+
preCheck?: string | null;
|
|
26
|
+
/** A manually disabled job is never run, even with a fresh stamp. */
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
/** ISO string on the wire (typed Date by the generated client). */
|
|
29
|
+
runRequestedAt?: string | Date | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunNowRequest {
|
|
33
|
+
jobId: string;
|
|
34
|
+
prompt: string;
|
|
35
|
+
channel?: string;
|
|
36
|
+
user?: string;
|
|
37
|
+
silent?: boolean;
|
|
38
|
+
preCheck?: string;
|
|
39
|
+
commandVariant: "run-now";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RunNowHonourerDeps {
|
|
43
|
+
clock: Clock;
|
|
44
|
+
/** Runs the job (handleCronRequest in production). */
|
|
45
|
+
dispatch: (req: RunNowRequest) => Promise<void>;
|
|
46
|
+
/** Called when a dispatch throws; the request is not retried. */
|
|
47
|
+
onError?: (jobId: string, err: unknown) => void | Promise<void>;
|
|
48
|
+
log?: (line: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns a function to call with the fresh job list on every sync tick. It
|
|
53
|
+
* resolves once every dispatch it started has settled; callers that must not
|
|
54
|
+
* wait on long runs simply do not await it.
|
|
55
|
+
*/
|
|
56
|
+
export function createRunNowHonourer(
|
|
57
|
+
deps: RunNowHonourerDeps,
|
|
58
|
+
): (jobs: readonly RunNowJob[]) => Promise<void> {
|
|
59
|
+
const { clock, dispatch, onError, log = console.log } = deps;
|
|
60
|
+
// jobId -> last honoured / last expired-logged runRequestedAt (epoch ms).
|
|
61
|
+
const lastHonoured = new Map<string, number>();
|
|
62
|
+
const expiredLogged = new Map<string, number>();
|
|
63
|
+
|
|
64
|
+
return async (jobs) => {
|
|
65
|
+
const running: Promise<void>[] = [];
|
|
66
|
+
for (const job of jobs) {
|
|
67
|
+
if (!job.runRequestedAt || job.enabled === false) continue;
|
|
68
|
+
const requestedMs = new Date(job.runRequestedAt).getTime();
|
|
69
|
+
if (Number.isNaN(requestedMs)) continue;
|
|
70
|
+
const requested = new Date(requestedMs).toISOString();
|
|
71
|
+
|
|
72
|
+
const seen = lastHonoured.get(job.id);
|
|
73
|
+
if (seen !== undefined && requestedMs <= seen) continue;
|
|
74
|
+
|
|
75
|
+
if (clock.now().getTime() - requestedMs > RUN_NOW_FRESH_MS) {
|
|
76
|
+
if (expiredLogged.get(job.id) !== requestedMs) {
|
|
77
|
+
expiredLogged.set(job.id, requestedMs);
|
|
78
|
+
log(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
event: "cron-run-now",
|
|
81
|
+
status: "expired",
|
|
82
|
+
jobId: job.id,
|
|
83
|
+
requestedAt: requested,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Recorded before dispatch so a slow or failing run is never repeated.
|
|
91
|
+
lastHonoured.set(job.id, requestedMs);
|
|
92
|
+
log(
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
event: "cron-run-now",
|
|
95
|
+
status: "honouring",
|
|
96
|
+
jobId: job.id,
|
|
97
|
+
requestedAt: requested,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
running.push(
|
|
101
|
+
dispatch({
|
|
102
|
+
jobId: job.id,
|
|
103
|
+
prompt: job.prompt,
|
|
104
|
+
channel: job.channel ?? undefined,
|
|
105
|
+
user: job.user ?? undefined,
|
|
106
|
+
silent: job.silent,
|
|
107
|
+
preCheck: job.preCheck ?? undefined,
|
|
108
|
+
commandVariant: "run-now",
|
|
109
|
+
}).catch(async (err) => {
|
|
110
|
+
await onError?.(job.id, err);
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
await Promise.all(running);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/cron-run-now.unit.test.ts
|
|
3
|
+
* Honour / expire / once logic for cron run-now requests (COS-42.1), with an
|
|
4
|
+
* injected clock and dispatch.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
createRunNowHonourer,
|
|
10
|
+
RUN_NOW_FRESH_MS,
|
|
11
|
+
type RunNowJob,
|
|
12
|
+
} from "./cron-run-now.ts";
|
|
13
|
+
|
|
14
|
+
const NOW = new Date("2026-10-26T12:00:00.000Z");
|
|
15
|
+
|
|
16
|
+
function job(over: Partial<RunNowJob> = {}): RunNowJob {
|
|
17
|
+
return {
|
|
18
|
+
id: "job-1",
|
|
19
|
+
prompt: "stored prompt",
|
|
20
|
+
channel: "C1",
|
|
21
|
+
user: null,
|
|
22
|
+
silent: false,
|
|
23
|
+
preCheck: null,
|
|
24
|
+
runRequestedAt: null,
|
|
25
|
+
...over,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setup() {
|
|
30
|
+
let nowMs = NOW.getTime();
|
|
31
|
+
const dispatched: Array<Record<string, unknown>> = [];
|
|
32
|
+
const logs: string[] = [];
|
|
33
|
+
const honour = createRunNowHonourer({
|
|
34
|
+
clock: { now: () => new Date(nowMs) },
|
|
35
|
+
dispatch: async (req) => {
|
|
36
|
+
dispatched.push(req as unknown as Record<string, unknown>);
|
|
37
|
+
},
|
|
38
|
+
log: (line) => logs.push(line),
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
honour,
|
|
42
|
+
dispatched,
|
|
43
|
+
logs,
|
|
44
|
+
advance: (ms: number) => {
|
|
45
|
+
nowMs += ms;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
|
|
51
|
+
|
|
52
|
+
describe("createRunNowHonourer", () => {
|
|
53
|
+
it("never runs a manually disabled job", async () => {
|
|
54
|
+
const t = setup();
|
|
55
|
+
await t.honour([job({ enabled: false, runRequestedAt: ago(1000) })]);
|
|
56
|
+
expect(t.dispatched).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("runs a fresh request once with the stored job fields and run-now variant", async () => {
|
|
60
|
+
const { honour, dispatched } = setup();
|
|
61
|
+
await honour([job({ runRequestedAt: ago(60_000) })]);
|
|
62
|
+
expect(dispatched).toEqual([
|
|
63
|
+
{
|
|
64
|
+
jobId: "job-1",
|
|
65
|
+
prompt: "stored prompt",
|
|
66
|
+
channel: "C1",
|
|
67
|
+
user: undefined,
|
|
68
|
+
silent: false,
|
|
69
|
+
preCheck: undefined,
|
|
70
|
+
commandVariant: "run-now",
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not run the same request twice across ticks", async () => {
|
|
76
|
+
const { honour, dispatched, advance } = setup();
|
|
77
|
+
const j = job({ runRequestedAt: ago(60_000) });
|
|
78
|
+
await honour([j]);
|
|
79
|
+
advance(60_000);
|
|
80
|
+
await honour([j]);
|
|
81
|
+
await honour([j]);
|
|
82
|
+
expect(dispatched).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("runs again for a strictly newer request", async () => {
|
|
86
|
+
const { honour, dispatched, advance } = setup();
|
|
87
|
+
await honour([job({ runRequestedAt: ago(60_000) })]);
|
|
88
|
+
advance(120_000);
|
|
89
|
+
await honour([job({ runRequestedAt: ago(10_000) })]);
|
|
90
|
+
expect(dispatched).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("ignores an older-or-equal stamp than the last honoured", async () => {
|
|
94
|
+
const { honour, dispatched } = setup();
|
|
95
|
+
await honour([job({ runRequestedAt: ago(10_000) })]);
|
|
96
|
+
await honour([job({ runRequestedAt: ago(30_000) })]);
|
|
97
|
+
expect(dispatched).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("ignores a request older than 15 minutes and logs expired once", async () => {
|
|
101
|
+
const { honour, dispatched, logs } = setup();
|
|
102
|
+
const j = job({ runRequestedAt: ago(RUN_NOW_FRESH_MS + 1) });
|
|
103
|
+
await honour([j]);
|
|
104
|
+
await honour([j]);
|
|
105
|
+
expect(dispatched).toEqual([]);
|
|
106
|
+
const expired = logs.filter((l) => l.includes("expired"));
|
|
107
|
+
expect(expired).toHaveLength(1);
|
|
108
|
+
expect(logs.join("\n")).not.toContain("stored prompt");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("a request exactly 15 minutes old is still fresh", async () => {
|
|
112
|
+
const { honour, dispatched } = setup();
|
|
113
|
+
await honour([job({ runRequestedAt: ago(RUN_NOW_FRESH_MS) })]);
|
|
114
|
+
expect(dispatched).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("a request that ages out between ticks without being seen expires", async () => {
|
|
118
|
+
const { honour, dispatched, logs, advance } = setup();
|
|
119
|
+
const j = job({ runRequestedAt: ago(0) });
|
|
120
|
+
advance(RUN_NOW_FRESH_MS + 60_000);
|
|
121
|
+
await honour([j]);
|
|
122
|
+
expect(dispatched).toEqual([]);
|
|
123
|
+
expect(logs.some((l) => l.includes("expired"))).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("skips jobs with no request", async () => {
|
|
127
|
+
const { honour, dispatched, logs } = setup();
|
|
128
|
+
await honour([job(), job({ id: "job-2" })]);
|
|
129
|
+
expect(dispatched).toEqual([]);
|
|
130
|
+
expect(logs).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a failing dispatch does not stop the other jobs and is not retried", async () => {
|
|
134
|
+
const dispatched: string[] = [];
|
|
135
|
+
const failures: string[] = [];
|
|
136
|
+
const honour = createRunNowHonourer({
|
|
137
|
+
clock: { now: () => NOW },
|
|
138
|
+
dispatch: async (req) => {
|
|
139
|
+
dispatched.push(req.jobId);
|
|
140
|
+
if (req.jobId === "bad") throw new Error("boom");
|
|
141
|
+
},
|
|
142
|
+
onError: (jobId) => {
|
|
143
|
+
failures.push(jobId);
|
|
144
|
+
},
|
|
145
|
+
log: () => {},
|
|
146
|
+
});
|
|
147
|
+
const jobs = [
|
|
148
|
+
job({ id: "bad", runRequestedAt: ago(1000) }),
|
|
149
|
+
job({ id: "good", runRequestedAt: ago(1000) }),
|
|
150
|
+
];
|
|
151
|
+
await honour(jobs);
|
|
152
|
+
await honour(jobs);
|
|
153
|
+
expect(dispatched).toEqual(["bad", "good"]);
|
|
154
|
+
expect(failures).toEqual(["bad"]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
InstallationState,
|
|
20
20
|
InstallationsSnapshot,
|
|
21
21
|
} from "./github-installations.ts";
|
|
22
|
+
import type { RepoAccessEntry, RepoAccessProbe } from "./repo-access-probe.ts";
|
|
22
23
|
|
|
23
24
|
export const INSTALLATIONS_HEARTBEAT_MS = 30 * 60 * 1000;
|
|
24
25
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -56,6 +57,11 @@ interface HttpGitHubInstallationsReporterOptions {
|
|
|
56
57
|
fetchFn?: typeof fetch;
|
|
57
58
|
clock?: Clock;
|
|
58
59
|
heartbeatMs?: number;
|
|
60
|
+
/**
|
|
61
|
+
* COS-44.1: per-repo access probe. It gates itself (flag, once per interval,
|
|
62
|
+
* scope change); the reporter just merges whatever it returns into the PUT.
|
|
63
|
+
*/
|
|
64
|
+
repoAccessProbe?: RepoAccessProbe;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
export class HttpGitHubInstallationsReporter
|
|
@@ -66,6 +72,7 @@ export class HttpGitHubInstallationsReporter
|
|
|
66
72
|
private readonly heartbeatMs: number;
|
|
67
73
|
private lastSignature: string | null = null;
|
|
68
74
|
private lastPostedAt = 0;
|
|
75
|
+
private lastRepos: RepoAccessEntry[] | undefined;
|
|
69
76
|
|
|
70
77
|
constructor(private opts: HttpGitHubInstallationsReporterOptions) {
|
|
71
78
|
this.fetchFn = opts.fetchFn ?? fetch;
|
|
@@ -77,13 +84,19 @@ export class HttpGitHubInstallationsReporter
|
|
|
77
84
|
try {
|
|
78
85
|
const installations = snapshot.installations.map(toReported);
|
|
79
86
|
const signature = JSON.stringify(installations);
|
|
87
|
+
const probed = await this.opts.repoAccessProbe?.run();
|
|
88
|
+
if (probed) this.lastRepos = probed;
|
|
80
89
|
const nowMs = this.clock.now().getTime();
|
|
81
|
-
const changed = signature !== this.lastSignature;
|
|
90
|
+
const changed = signature !== this.lastSignature || probed !== undefined;
|
|
82
91
|
if (!changed && nowMs - this.lastPostedAt < this.heartbeatMs) return;
|
|
83
92
|
// Claim the slot before awaiting so overlapping reports don't double-post.
|
|
84
93
|
this.lastSignature = signature;
|
|
85
94
|
this.lastPostedAt = nowMs;
|
|
86
|
-
await this.put(
|
|
95
|
+
await this.put(
|
|
96
|
+
installations,
|
|
97
|
+
new Date(nowMs).toISOString(),
|
|
98
|
+
this.lastRepos,
|
|
99
|
+
);
|
|
87
100
|
} catch (err) {
|
|
88
101
|
console.warn(
|
|
89
102
|
`[github-installations-reporter] report failed: ${String(err)} — swallowing`,
|
|
@@ -94,6 +107,7 @@ export class HttpGitHubInstallationsReporter
|
|
|
94
107
|
private async put(
|
|
95
108
|
installations: ReportedInstallation[],
|
|
96
109
|
reportedAt: string,
|
|
110
|
+
repos: RepoAccessEntry[] | undefined,
|
|
97
111
|
): Promise<void> {
|
|
98
112
|
const { apiUrl, agentId, apiKey } = this.opts;
|
|
99
113
|
const url = `${apiUrl}/agents/${agentId}/github-installations`;
|
|
@@ -104,7 +118,11 @@ export class HttpGitHubInstallationsReporter
|
|
|
104
118
|
"Content-Type": "application/json",
|
|
105
119
|
Authorization: `Bearer ${apiKey}`,
|
|
106
120
|
},
|
|
107
|
-
body: JSON.stringify({
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
reportedAt,
|
|
123
|
+
installations,
|
|
124
|
+
...(repos ? { repos } : {}),
|
|
125
|
+
}),
|
|
108
126
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
109
127
|
});
|
|
110
128
|
if (!res.ok) {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
import type { InstallationsSnapshot } from "./github-installations.ts";
|
|
8
|
+
import type { RepoAccessEntry, RepoAccessProbe } from "./repo-access-probe.ts";
|
|
8
9
|
import {
|
|
9
10
|
HttpGitHubInstallationsReporter,
|
|
10
11
|
INSTALLATIONS_HEARTBEAT_MS,
|
|
@@ -31,7 +32,10 @@ function snap(
|
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
function harness(
|
|
35
|
+
function harness(
|
|
36
|
+
responses: (Response | Error)[] = [],
|
|
37
|
+
repoAccessProbe?: RepoAccessProbe,
|
|
38
|
+
) {
|
|
35
39
|
let nowMs = Date.UTC(2026, 0, 1);
|
|
36
40
|
const calls: { url: string; init: RequestInit | undefined }[] = [];
|
|
37
41
|
let i = 0;
|
|
@@ -47,6 +51,7 @@ function harness(responses: (Response | Error)[] = []) {
|
|
|
47
51
|
apiKey: "KEY",
|
|
48
52
|
fetchFn,
|
|
49
53
|
clock: { now: () => new Date(nowMs) },
|
|
54
|
+
repoAccessProbe,
|
|
50
55
|
});
|
|
51
56
|
return {
|
|
52
57
|
reporter,
|
|
@@ -164,3 +169,48 @@ describe("NoopGitHubInstallationsReporter", () => {
|
|
|
164
169
|
await new NoopGitHubInstallationsReporter().report(snap());
|
|
165
170
|
});
|
|
166
171
|
});
|
|
172
|
+
|
|
173
|
+
describe("HttpGitHubInstallationsReporter repo access (COS-44.1)", () => {
|
|
174
|
+
const entry: RepoAccessEntry = {
|
|
175
|
+
repo: "a",
|
|
176
|
+
owner: "acme",
|
|
177
|
+
read: true,
|
|
178
|
+
write: false,
|
|
179
|
+
checkedAt: "2026-01-01T00:00:00.000Z",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
test("a probe result is merged into the PUT body as repos", async () => {
|
|
183
|
+
const h = harness([], { run: async () => [entry] });
|
|
184
|
+
await h.reporter.report(snap());
|
|
185
|
+
expect(JSON.parse(String(h.calls[0].init?.body)).repos).toEqual([entry]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("a fresh probe result posts even when installations are unchanged", async () => {
|
|
189
|
+
let result: RepoAccessEntry[] | undefined = [entry];
|
|
190
|
+
const h = harness([], { run: async () => result });
|
|
191
|
+
await h.reporter.report(snap());
|
|
192
|
+
result = [{ ...entry, write: true }];
|
|
193
|
+
h.advance(60_000);
|
|
194
|
+
await h.reporter.report(snap());
|
|
195
|
+
expect(h.calls).toHaveLength(2);
|
|
196
|
+
expect(JSON.parse(String(h.calls[1].init?.body)).repos[0].write).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("later heartbeats re-send the last probe result", async () => {
|
|
200
|
+
let result: RepoAccessEntry[] | undefined = [entry];
|
|
201
|
+
const h = harness([], { run: async () => result });
|
|
202
|
+
await h.reporter.report(snap());
|
|
203
|
+
result = undefined;
|
|
204
|
+
h.advance(INSTALLATIONS_HEARTBEAT_MS);
|
|
205
|
+
await h.reporter.report(snap());
|
|
206
|
+
expect(JSON.parse(String(h.calls[1].init?.body)).repos).toEqual([entry]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("no probe result -> body has no repos key", async () => {
|
|
210
|
+
const h = harness([], { run: async () => undefined });
|
|
211
|
+
await h.reporter.report(snap());
|
|
212
|
+
expect(
|
|
213
|
+
Object.keys(JSON.parse(String(h.calls[0].init?.body))).sort(),
|
|
214
|
+
).toEqual(["installations", "reportedAt"]);
|
|
215
|
+
});
|
|
216
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"./workspace-paths": "./workspace-paths.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@app-vitals/agent-admin": "0.
|
|
44
|
-
"@app-vitals/agent-lib": "0.
|
|
43
|
+
"@app-vitals/agent-admin": "0.5.0",
|
|
44
|
+
"@app-vitals/agent-lib": "0.5.0",
|
|
45
45
|
"@octokit/auth-app": "^8.2.0",
|
|
46
46
|
"@sentry/bun": "^10.63.0",
|
|
47
47
|
"@slack/bolt": "^5.0.0",
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/repo-access-probe.ts
|
|
3
|
+
*
|
|
4
|
+
* Per-repo GitHub access probe (COS-44.1). For each repo in the agent's scope it
|
|
5
|
+
* calls GET /repos/{owner}/{repo} with the owner's installation token (or the
|
|
6
|
+
* PAT) and maps `permissions.pull` -> read, `permissions.push` -> write. A
|
|
7
|
+
* failure becomes read:false, write:false plus a fixed error class
|
|
8
|
+
* (not_found | forbidden | rate_limited | network).
|
|
9
|
+
*
|
|
10
|
+
* Gating lives here, not in the caller: run() does nothing (zero fetch calls)
|
|
11
|
+
* unless the feature flag is on, and then probes at most once per interval or
|
|
12
|
+
* when the repo scope changed. Concurrency is capped at 3. The token is passed
|
|
13
|
+
* only in the Authorization header — never logged, never in the result.
|
|
14
|
+
* Everything is injected so it is deterministically testable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Clock } from "./clock.ts";
|
|
18
|
+
|
|
19
|
+
export const REPO_ACCESS_INTERVAL_MS = 30 * 60 * 1000;
|
|
20
|
+
const REPO_ACCESS_CONCURRENCY = 3;
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
export type RepoAccessErrorClass =
|
|
24
|
+
| "not_found"
|
|
25
|
+
| "forbidden"
|
|
26
|
+
| "rate_limited"
|
|
27
|
+
| "network";
|
|
28
|
+
|
|
29
|
+
export interface RepoAccessEntry {
|
|
30
|
+
repo: string;
|
|
31
|
+
owner: string;
|
|
32
|
+
read: boolean;
|
|
33
|
+
write: boolean;
|
|
34
|
+
checkedAt: string;
|
|
35
|
+
error?: RepoAccessErrorClass;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RepoAccessProbeDeps {
|
|
39
|
+
fetch: typeof fetch;
|
|
40
|
+
clock: Clock;
|
|
41
|
+
/** Token for the owner's installation, the PAT, or null when there is none. */
|
|
42
|
+
tokenFor(owner: string): Promise<string | null>;
|
|
43
|
+
log(message: string): void;
|
|
44
|
+
/** Scoped repos as `org/repo` strings. */
|
|
45
|
+
getRepos(): string[];
|
|
46
|
+
isEnabled(): boolean;
|
|
47
|
+
intervalMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RepoAccessProbe {
|
|
51
|
+
/**
|
|
52
|
+
* Returns a fresh result when a probe round ran, or undefined when it did
|
|
53
|
+
* not (feature off, or not due and the scope is unchanged).
|
|
54
|
+
*/
|
|
55
|
+
run(): Promise<RepoAccessEntry[] | undefined>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function classify(res: Response): RepoAccessErrorClass {
|
|
59
|
+
if (res.status === 404) return "not_found";
|
|
60
|
+
if (res.status === 429) return "rate_limited";
|
|
61
|
+
if (res.status === 403) {
|
|
62
|
+
return res.headers.get("x-ratelimit-remaining") === "0"
|
|
63
|
+
? "rate_limited"
|
|
64
|
+
: "forbidden";
|
|
65
|
+
}
|
|
66
|
+
if (res.status === 401) return "forbidden";
|
|
67
|
+
return "network";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function failed(
|
|
71
|
+
owner: string,
|
|
72
|
+
repo: string,
|
|
73
|
+
checkedAt: string,
|
|
74
|
+
error: RepoAccessErrorClass,
|
|
75
|
+
): RepoAccessEntry {
|
|
76
|
+
return { repo, owner, read: false, write: false, checkedAt, error };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createRepoAccessProbe(
|
|
80
|
+
deps: RepoAccessProbeDeps,
|
|
81
|
+
): RepoAccessProbe {
|
|
82
|
+
const intervalMs = deps.intervalMs ?? REPO_ACCESS_INTERVAL_MS;
|
|
83
|
+
let lastScope: string | null = null;
|
|
84
|
+
let lastRunAt = 0;
|
|
85
|
+
let running = false;
|
|
86
|
+
|
|
87
|
+
async function probeOne(slug: string): Promise<RepoAccessEntry> {
|
|
88
|
+
const [owner = "", repo = ""] = slug.split("/");
|
|
89
|
+
const checkedAt = deps.clock.now().toISOString();
|
|
90
|
+
try {
|
|
91
|
+
const token = await deps.tokenFor(owner);
|
|
92
|
+
if (!token) return failed(owner, repo, checkedAt, "forbidden");
|
|
93
|
+
const res = await deps.fetch(
|
|
94
|
+
`https://api.github.com/repos/${owner}/${repo}`,
|
|
95
|
+
{
|
|
96
|
+
headers: {
|
|
97
|
+
Authorization: `Bearer ${token}`,
|
|
98
|
+
Accept: "application/vnd.github+json",
|
|
99
|
+
},
|
|
100
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
if (!res.ok) return failed(owner, repo, checkedAt, classify(res));
|
|
104
|
+
const body = (await res.json()) as {
|
|
105
|
+
permissions?: { pull?: boolean; push?: boolean };
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
repo,
|
|
109
|
+
owner,
|
|
110
|
+
read: body.permissions?.pull === true,
|
|
111
|
+
write: body.permissions?.push === true,
|
|
112
|
+
checkedAt,
|
|
113
|
+
};
|
|
114
|
+
} catch {
|
|
115
|
+
// Never interpolate the error: a thrown message could carry request detail.
|
|
116
|
+
return failed(owner, repo, checkedAt, "network");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function probeAll(slugs: string[]): Promise<RepoAccessEntry[]> {
|
|
121
|
+
const results: RepoAccessEntry[] = new Array(slugs.length);
|
|
122
|
+
let next = 0;
|
|
123
|
+
const worker = async () => {
|
|
124
|
+
while (next < slugs.length) {
|
|
125
|
+
const i = next++;
|
|
126
|
+
results[i] = await probeOne(slugs[i] as string);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
await Promise.all(
|
|
130
|
+
Array.from(
|
|
131
|
+
{ length: Math.min(REPO_ACCESS_CONCURRENCY, slugs.length) },
|
|
132
|
+
worker,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
async run() {
|
|
140
|
+
if (!deps.isEnabled() || running) return undefined;
|
|
141
|
+
const slugs = [...new Set(deps.getRepos())]
|
|
142
|
+
.filter((r) => r.includes("/"))
|
|
143
|
+
.sort();
|
|
144
|
+
const scope = slugs.join(",");
|
|
145
|
+
const nowMs = deps.clock.now().getTime();
|
|
146
|
+
const scopeChanged = scope !== lastScope;
|
|
147
|
+
if (!scopeChanged && nowMs - lastRunAt < intervalMs) return undefined;
|
|
148
|
+
running = true;
|
|
149
|
+
lastScope = scope;
|
|
150
|
+
lastRunAt = nowMs;
|
|
151
|
+
try {
|
|
152
|
+
const results = await probeAll(slugs);
|
|
153
|
+
deps.log(
|
|
154
|
+
`[repo-access] probed ${results.length} repo(s), ${results.filter((r) => r.error).length} with errors`,
|
|
155
|
+
);
|
|
156
|
+
return results;
|
|
157
|
+
} finally {
|
|
158
|
+
running = false;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface InstallationTokenSource {
|
|
165
|
+
getState?(): {
|
|
166
|
+
installations: { id: number; owner: string | null; health: string }[];
|
|
167
|
+
};
|
|
168
|
+
/** Present on the real manager; the ref's narrower type omits it. */
|
|
169
|
+
getToken?(installationId: number): Promise<string>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Builds `tokenFor`: the healthy installation whose owner matches (case
|
|
174
|
+
* insensitive), else the PAT from GH_TOKEN, else null. The manager is read
|
|
175
|
+
* lazily because App auth may activate after boot.
|
|
176
|
+
*/
|
|
177
|
+
export function createRepoAccessTokenFor(
|
|
178
|
+
getManager: () => InstallationTokenSource | null | undefined,
|
|
179
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
180
|
+
): (owner: string) => Promise<string | null> {
|
|
181
|
+
return async (owner) => {
|
|
182
|
+
const manager = getManager();
|
|
183
|
+
const match = manager
|
|
184
|
+
?.getState?.()
|
|
185
|
+
.installations.find(
|
|
186
|
+
(i) =>
|
|
187
|
+
i.owner?.toLowerCase() === owner.toLowerCase() &&
|
|
188
|
+
i.health !== "broken",
|
|
189
|
+
);
|
|
190
|
+
if (manager?.getToken && match) return manager.getToken(match.id);
|
|
191
|
+
return env.GH_TOKEN || null;
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/repo-access-probe.unit.test.ts
|
|
3
|
+
* Probe behaviour via a routed fetch fake and an injected clock (COS-44.1).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
createRepoAccessProbe,
|
|
9
|
+
createRepoAccessTokenFor,
|
|
10
|
+
REPO_ACCESS_INTERVAL_MS,
|
|
11
|
+
} from "./repo-access-probe.ts";
|
|
12
|
+
|
|
13
|
+
const SECRET = "ghs_SUPERSECRETTOKEN";
|
|
14
|
+
|
|
15
|
+
type Route = Response | Error | ((req: { url: string }) => Response);
|
|
16
|
+
|
|
17
|
+
function harness(
|
|
18
|
+
routes: Record<string, Route>,
|
|
19
|
+
opts: { repos?: string[]; enabled?: boolean; token?: string | null } = {},
|
|
20
|
+
) {
|
|
21
|
+
let nowMs = Date.UTC(2026, 0, 1);
|
|
22
|
+
const calls: { url: string; auth: string | null }[] = [];
|
|
23
|
+
const logs: string[] = [];
|
|
24
|
+
let inFlight = 0;
|
|
25
|
+
let maxInFlight = 0;
|
|
26
|
+
const state = {
|
|
27
|
+
repos: opts.repos ?? ["acme/a"],
|
|
28
|
+
enabled: opts.enabled ?? true,
|
|
29
|
+
};
|
|
30
|
+
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
31
|
+
const url = String(input);
|
|
32
|
+
calls.push({
|
|
33
|
+
url,
|
|
34
|
+
auth: new Headers(init?.headers).get("authorization"),
|
|
35
|
+
});
|
|
36
|
+
inFlight++;
|
|
37
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
38
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
39
|
+
inFlight--;
|
|
40
|
+
const route = routes[url.replace("https://api.github.com/repos/", "")];
|
|
41
|
+
if (route instanceof Error) throw route;
|
|
42
|
+
if (typeof route === "function") return route({ url });
|
|
43
|
+
return route ?? new Response("{}", { status: 404 });
|
|
44
|
+
}) as unknown as typeof fetch;
|
|
45
|
+
const probe = createRepoAccessProbe({
|
|
46
|
+
fetch: fetchFn,
|
|
47
|
+
clock: { now: () => new Date(nowMs) },
|
|
48
|
+
tokenFor: async () => (opts.token === undefined ? SECRET : opts.token),
|
|
49
|
+
log: (m) => logs.push(m),
|
|
50
|
+
getRepos: () => state.repos,
|
|
51
|
+
isEnabled: () => state.enabled,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
probe,
|
|
55
|
+
calls,
|
|
56
|
+
logs,
|
|
57
|
+
state,
|
|
58
|
+
maxInFlight: () => maxInFlight,
|
|
59
|
+
advance: (ms: number) => {
|
|
60
|
+
nowMs += ms;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ok = (pull: boolean, push: boolean) =>
|
|
66
|
+
new Response(JSON.stringify({ permissions: { pull, push } }), {
|
|
67
|
+
status: 200,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("createRepoAccessProbe", () => {
|
|
71
|
+
test("maps permissions.pull/push to read/write", async () => {
|
|
72
|
+
const h = harness(
|
|
73
|
+
{
|
|
74
|
+
"acme/rw": ok(true, true),
|
|
75
|
+
"acme/ro": ok(true, false),
|
|
76
|
+
},
|
|
77
|
+
{ repos: ["acme/rw", "acme/ro"] },
|
|
78
|
+
);
|
|
79
|
+
const result = await h.probe.run();
|
|
80
|
+
expect(result).toEqual([
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
repo: "ro",
|
|
83
|
+
owner: "acme",
|
|
84
|
+
read: true,
|
|
85
|
+
write: false,
|
|
86
|
+
}),
|
|
87
|
+
expect.objectContaining({
|
|
88
|
+
repo: "rw",
|
|
89
|
+
owner: "acme",
|
|
90
|
+
read: true,
|
|
91
|
+
write: true,
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
94
|
+
expect(result?.every((r) => r.error === undefined)).toBe(true);
|
|
95
|
+
expect(result?.[0]?.checkedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test.each([
|
|
99
|
+
["404", new Response("", { status: 404 }), "not_found"],
|
|
100
|
+
["403", new Response("", { status: 403 }), "forbidden"],
|
|
101
|
+
[
|
|
102
|
+
"403 with exhausted rate limit",
|
|
103
|
+
new Response("", {
|
|
104
|
+
status: 403,
|
|
105
|
+
headers: { "x-ratelimit-remaining": "0" },
|
|
106
|
+
}),
|
|
107
|
+
"rate_limited",
|
|
108
|
+
],
|
|
109
|
+
["429", new Response("", { status: 429 }), "rate_limited"],
|
|
110
|
+
["network error", new Error("ECONNRESET"), "network"],
|
|
111
|
+
["5xx", new Response("", { status: 502 }), "network"],
|
|
112
|
+
] as const)(
|
|
113
|
+
"%s -> read:false, write:false, error %s",
|
|
114
|
+
async (_n, route, error) => {
|
|
115
|
+
const h = harness({ "acme/a": route });
|
|
116
|
+
const result = await h.probe.run();
|
|
117
|
+
expect(result).toEqual([
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
repo: "a",
|
|
120
|
+
read: false,
|
|
121
|
+
write: false,
|
|
122
|
+
error,
|
|
123
|
+
}),
|
|
124
|
+
]);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
test("no credential for the owner -> forbidden without a fetch", async () => {
|
|
129
|
+
const h = harness({}, { token: null });
|
|
130
|
+
const result = await h.probe.run();
|
|
131
|
+
expect(result?.[0]?.error).toBe("forbidden");
|
|
132
|
+
expect(h.calls).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("feature off -> no calls and no result", async () => {
|
|
136
|
+
const h = harness({ "acme/a": ok(true, true) }, { enabled: false });
|
|
137
|
+
expect(await h.probe.run()).toBeUndefined();
|
|
138
|
+
expect(h.calls).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("at most once per interval, again after it elapses", async () => {
|
|
142
|
+
const h = harness({ "acme/a": ok(true, true) });
|
|
143
|
+
expect(await h.probe.run()).toBeDefined();
|
|
144
|
+
h.advance(REPO_ACCESS_INTERVAL_MS - 1);
|
|
145
|
+
expect(await h.probe.run()).toBeUndefined();
|
|
146
|
+
h.advance(1);
|
|
147
|
+
expect(await h.probe.run()).toBeDefined();
|
|
148
|
+
expect(h.calls).toHaveLength(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a scope change probes immediately", async () => {
|
|
152
|
+
const h = harness({ "acme/a": ok(true, true), "acme/b": ok(true, false) });
|
|
153
|
+
await h.probe.run();
|
|
154
|
+
h.state.repos = ["acme/a", "acme/b"];
|
|
155
|
+
const result = await h.probe.run();
|
|
156
|
+
expect(result).toHaveLength(2);
|
|
157
|
+
// Same set in a different order is not a change.
|
|
158
|
+
h.state.repos = ["acme/b", "acme/a"];
|
|
159
|
+
expect(await h.probe.run()).toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("concurrency is capped at 3", async () => {
|
|
163
|
+
const repos = Array.from({ length: 8 }, (_, i) => `acme/r${i}`);
|
|
164
|
+
const routes = Object.fromEntries(repos.map((r) => [r, ok(true, true)]));
|
|
165
|
+
const h = harness(routes, { repos });
|
|
166
|
+
await h.probe.run();
|
|
167
|
+
expect(h.calls).toHaveLength(8);
|
|
168
|
+
expect(h.maxInFlight()).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("the token is sent only as a header and never logged or returned", async () => {
|
|
172
|
+
const h = harness({
|
|
173
|
+
"acme/a": new Error(`boom with ${SECRET}`),
|
|
174
|
+
});
|
|
175
|
+
const result = await h.probe.run();
|
|
176
|
+
expect(h.calls[0]?.auth).toBe(`Bearer ${SECRET}`);
|
|
177
|
+
expect(JSON.stringify(result)).not.toContain(SECRET);
|
|
178
|
+
expect(h.logs.join("\n")).not.toContain(SECRET);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("createRepoAccessTokenFor", () => {
|
|
183
|
+
const manager = {
|
|
184
|
+
getState: () => ({
|
|
185
|
+
installations: [
|
|
186
|
+
{ id: 1, owner: "Acme", health: "ok" },
|
|
187
|
+
{ id: 2, owner: "dead", health: "broken" },
|
|
188
|
+
],
|
|
189
|
+
}),
|
|
190
|
+
getToken: async (id: number) => `inst-${id}`,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
test("uses the matching healthy installation (case-insensitive)", async () => {
|
|
194
|
+
const tokenFor = createRepoAccessTokenFor(() => manager, {
|
|
195
|
+
GH_TOKEN: "pat",
|
|
196
|
+
});
|
|
197
|
+
expect(await tokenFor("acme")).toBe("inst-1");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("falls back to the PAT, then null", async () => {
|
|
201
|
+
expect(
|
|
202
|
+
await createRepoAccessTokenFor(() => manager, { GH_TOKEN: "pat" })(
|
|
203
|
+
"dead",
|
|
204
|
+
),
|
|
205
|
+
).toBe("pat");
|
|
206
|
+
expect(
|
|
207
|
+
await createRepoAccessTokenFor(() => null, { GH_TOKEN: "pat" })("x"),
|
|
208
|
+
).toBe("pat");
|
|
209
|
+
expect(await createRepoAccessTokenFor(() => null, {})("x")).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -53,6 +53,8 @@ const SAMPLE_CRONS: AgentCronJob[] = [
|
|
|
53
53
|
system: false,
|
|
54
54
|
parentCronId: null,
|
|
55
55
|
lockdownDisabledAt: null,
|
|
56
|
+
runRequestedAt: null,
|
|
57
|
+
runRequestedBy: null,
|
|
56
58
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
|
57
59
|
updatedAt: new Date("2025-01-01T00:00:00Z"),
|
|
58
60
|
},
|