@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 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(jobId, startedAt)
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)", () => {
@@ -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
+ });
@@ -35,6 +35,8 @@ function makeCron(id: string): AgentCronJob {
35
35
  system: false,
36
36
  parentCronId: null,
37
37
  lockdownDisabledAt: null,
38
+ runRequestedAt: null,
39
+ runRequestedBy: null,
38
40
  };
39
41
  }
40
42
 
@@ -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(installations, new Date(nowMs).toISOString());
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({ reportedAt, installations }),
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(responses: (Response | Error)[] = []) {
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.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.3.0",
44
- "@app-vitals/agent-lib": "0.3.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
  },