Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

autopilot.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

autopilot.tsBlame1323 lines · 3 contributors
2b821b7Claude1/**
2 * Autopilot — self-sufficiency loop.
3 *
4 * Runs existing platform-maintenance tasks (mirror sync, merge queue progress,
5 * weekly digests, advisory rescans) on an interval so the host runs itself
6 * without an external cron. All sub-tasks are injected so tests can stub them
7 * without touching the DB; the default task set wires real helpers from the
8 * locked libs. Nothing here throws — every sub-task and the outer tick are
9 * try/caught so a single failure never blocks the others.
10 */
11
2b9055eClaude12import { and, eq, gte, sql } from "drizzle-orm";
2b821b7Claude13import { db } from "../db";
2b9055eClaude14import {
15 mergeQueueEntries,
16 prComments,
17 pullRequests,
18 repoDependencies,
19 repositories,
20 users,
21} from "../db/schema";
2b821b7Claude22import { syncAllDue } from "./mirrors";
23import { peekHead } from "./merge-queue";
24import { sendDigestsToAll } from "./email-digest";
25import { scanRepositoryForAlerts } from "./advisories";
a76d984Claude26import { releaseExpiredWaitTimers } from "./environments";
665c8bfClaude27import { runScheduledWorkflowsTick } from "./scheduled-workflows";
2b9055eClaude28import {
29 evaluateAutoMerge,
30 recordAutoMergeAttempt,
31 type AutoMergeContext,
32 type AutoMergeDecision,
33} from "./auto-merge";
34import { matchProtection } from "./branch-protection";
35import { performMerge, type PerformMergeResult } from "./pr-merge";
36import { audit } from "./notify";
37import { runAiBuildTaskOnce } from "./ai-build-tasks";
46d6165Claude38import {
39 sendSleepModeDigestForUser,
40 SLEEP_MODE_USER_CAP_PER_TICK,
41 SLEEP_MODE_COOLDOWN_HOURS,
42} from "./sleep-mode";
534f04aClaude43import {
44 runStalePrSweepOnce,
45 runStaleIssueSweepOnce,
46} from "./stale-sweep";
47import { computePrRiskForPullRequest } from "./pr-risk";
48import { prRiskScores } from "../db/schema";
c63b860Claude49import { purgeScheduledAccounts } from "./account-deletion";
cd4f63bTest User50import { purgeExpiredPlaygroundAccounts } from "./playground";
b1be050CC LABS App51import {
52 runSyntheticChecks,
53 persistChecks,
54 latestStatusByCheck,
55 type SyntheticCheckResult,
56} from "./synthetic-monitor";
e9aa4d8Claude57import { aiProactiveMonitorTick } from "./ai-proactive-monitor";
9336f45Claude58import { runCiHealerTick } from "./ai-ci-healer";
a686079Claude59import {
60 runDailyStandupTaskOnce,
61 runWeeklyStandupTaskOnce,
62} from "./ai-standup";
950ef90Claude63import { runSpecToPrTaskOnce } from "./autopilot-spec-to-pr";
45f3b73Claude64import { runMigrationWatcherTaskOnce } from "./migration-assistant";
3c03977Claude65import { sweepStale as sweepStalePrLive } from "./pr-live";
2b821b7Claude66
67export interface AutopilotTaskResult {
68 name: string;
69 ok: boolean;
70 durationMs: number;
71 error?: string;
72}
73
74export interface AutopilotTickResult {
75 startedAt: string;
76 finishedAt: string;
77 tasks: AutopilotTaskResult[];
78}
79
80export interface AutopilotTask {
81 name: string;
82 run: () => Promise<void>;
83}
84
85export interface StartAutopilotOpts {
86 intervalMs?: number;
87 now?: () => number;
88 tasks?: AutopilotTask[];
89}
90
91export interface RunTickOpts {
92 tasks?: AutopilotTask[];
93 now?: () => number;
94}
95
96const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
97const ADVISORY_RESCAN_BATCH = 5;
2b9055eClaude98/** K3 — recency window for auto-merge candidate selection. */
99const AUTO_MERGE_LOOKBACK_HOURS = 24;
100/** K3 — hard cap on PRs evaluated per tick (runaway protection). */
101const AUTO_MERGE_MAX_PER_TICK = 50;
102/** K3 — stable marker for the auto-merge audit comment. */
103const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";
534f04aClaude104/** M3 — hard cap on PRs scored per tick (runaway protection). */
105const PR_RISK_RESCORE_MAX_PER_TICK = 20;
106/** M3 — recency window for the pr-risk-rescore sweep. */
107const PR_RISK_RESCORE_LOOKBACK_HOURS = 1;
e9aa4d8Claude108/** Proactive monitor cadence — Claude scans platform telemetry hourly. */
109const PROACTIVE_MONITOR_INTERVAL_MS = 60 * 60 * 1000;
110let _lastProactiveMonitorAt = 0;
950ef90Claude111/** Spec-to-PR cadence — autopilot scans `.gluecron/specs/*.md` every 2 minutes. */
112const SPEC_TO_PR_INTERVAL_MS = 2 * 60 * 1000;
113let _lastSpecToPrAt = 0;
45f3b73Claude114/**
115 * Migration watcher cadence. The lookup is cheap (registry calls per
116 * declared dep) but we still throttle to every 6 hours so we don't hammer
117 * npm and don't propose more than ~one PR per repo per day.
118 */
119const MIGRATION_WATCHER_INTERVAL_MS = 6 * 60 * 60 * 1000;
120let _lastMigrationWatcherAt = 0;
2b821b7Claude121
122/**
123 * Default task set. Each task is a thin wrapper around an existing locked
124 * helper — no gate/merge logic is duplicated here.
125 */
126export function defaultTasks(): AutopilotTask[] {
127 return [
128 {
129 name: "mirror-sync",
130 run: async () => {
131 await syncAllDue();
132 },
133 },
134 {
135 name: "merge-queue",
136 run: async () => {
137 await processMergeQueues();
138 },
139 },
140 {
141 name: "weekly-digest",
142 run: async () => {
143 await sendDigestsToAll();
144 },
145 },
146 {
147 name: "advisory-rescan",
148 run: async () => {
149 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
150 },
151 },
a76d984Claude152 {
153 name: "wait-timer-release",
154 run: async () => {
155 await releaseExpiredWaitTimers();
156 },
157 },
665c8bfClaude158 {
159 name: "scheduled-workflows",
160 run: async () => {
161 await runScheduledWorkflowsTick();
162 },
163 },
2b9055eClaude164 {
165 name: "auto-merge-sweep",
166 run: async () => {
167 await runAutoMergeSweep();
168 },
169 },
170 {
171 name: "ai-build-from-issues",
172 run: async () => {
173 const summary = await runAiBuildTaskOnce();
174 console.log(
175 `[autopilot] ai-build: queued=${summary.queued} skipped=${summary.skipped}`
176 );
177 },
178 },
46d6165Claude179 {
180 name: "sleep-mode-digest",
181 run: async () => {
182 const summary = await runSleepModeDigestTaskOnce();
183 console.log(
184 `[autopilot] sleep-mode-digest: sent=${summary.sent} skipped=${summary.skipped}`
185 );
186 },
187 },
534f04aClaude188 {
189 name: "stale-pr-sweep",
190 run: async () => {
191 // Two-stage gate: poke at 7d stale, close at 14d after poke
192 // (when the repo opts in via `auto_close_stale_prs`).
193 // Wrapped in try/catch so a finder crash never wedges the tick.
194 try {
195 const summary = await runStalePrSweepOnce();
196 console.log(
197 `[autopilot] stale-pr-sweep: poked=${summary.poked} closed=${summary.closed}`
198 );
199 } catch (err) {
200 console.error("[autopilot] stale-pr-sweep: threw:", err);
201 }
202 },
203 },
204 {
205 name: "stale-issue-sweep",
206 run: async () => {
207 // Mirror of stale-pr-sweep with the issue thresholds (30d/60d).
208 try {
209 const summary = await runStaleIssueSweepOnce();
210 console.log(
211 `[autopilot] stale-issue-sweep: poked=${summary.poked} closed=${summary.closed}`
212 );
213 } catch (err) {
214 console.error("[autopilot] stale-issue-sweep: threw:", err);
215 }
216 },
217 },
218 {
219 name: "pr-risk-rescore",
220 run: async () => {
221 const summary = await runPrRiskRescoreTaskOnce();
222 console.log(
223 `[autopilot] pr-risk-rescore: scored=${summary.scored} skipped=${summary.skipped}`
224 );
225 },
226 },
c63b860Claude227
228 {
229 // Block P5 — Hard-delete users whose 30-day grace period expired.
230 name: "account-purge",
231 run: async () => {
232 try {
233 const summary = await purgeScheduledAccounts({ cap: 50 });
234 console.log(
235 `[autopilot] account-purge: purged=${summary.purged} errors=${summary.errors}`
236 );
237 } catch (err) {
238 console.error("[autopilot] account-purge: threw:", err);
239 }
240 },
241 },
cd4f63bTest User242 {
243 // Block Q3 — Hard-delete anonymous playground accounts past their
244 // 24h TTL. CASCADE handles repos, sessions, issues. Per-user
245 // try/catch in the lib so one FK violation can't stall the queue.
246 name: "playground-purge",
247 run: async () => {
248 try {
249 const summary = await purgeExpiredPlaygroundAccounts({ cap: 50 });
250 console.log(
251 `[autopilot] playground-purge: purged=${summary.purged} errors=${summary.errors}`
252 );
253 } catch (err) {
254 console.error("[autopilot] playground-purge: threw:", err);
255 }
256 },
257 },
e9aa4d8Claude258 {
259 // Proactive AI monitor — hourly. Claude reads 24h of audit_log +
260 // platformDeploys + workflowRuns and opens issues on anomalies
261 // (degraded deploy times, recurring failures, suspicious audit
262 // patterns). Skips when ANTHROPIC_API_KEY is unset (the lib
263 // itself short-circuits, but the cadence gate avoids redundant
264 // work on every 5-min tick too).
265 name: "ai-proactive-monitor",
266 run: async () => {
267 if (!process.env.ANTHROPIC_API_KEY) return;
268 const now = Date.now();
269 if (now - _lastProactiveMonitorAt < PROACTIVE_MONITOR_INTERVAL_MS) {
270 return;
271 }
272 _lastProactiveMonitorAt = now;
273 try {
274 const summary = await aiProactiveMonitorTick();
275 console.log(
276 `[autopilot] ai-proactive-monitor: opened=${summary.opened} considered=${summary.considered} dedup=${summary.skippedDedupe}`
277 );
278 } catch (err) {
279 console.error("[autopilot] ai-proactive-monitor: threw:", err);
280 }
281 },
282 },
9336f45Claude283 {
284 // AI CI Healer — autonomous CI failure → root-cause → patch PR loop.
285 // Polls every tick (5 min) for failed workflow_runs that finished
286 // at least HEAL_MIN_AGE_MS ago and haven't been processed yet.
950ef90Claude287 // Skips when ANTHROPIC_API_KEY is unset.
9336f45Claude288 name: "ci-healer",
289 run: async () => {
290 if (!process.env.ANTHROPIC_API_KEY) return;
291 try {
292 const summary = await runCiHealerTick();
293 console.log(
294 `[autopilot] ci-healer: considered=${summary.considered} healed=${summary.healed} gaveUp=${summary.gaveUp} skipped=${summary.skipped}`
295 );
296 } catch (err) {
297 console.error("[autopilot] ci-healer: threw:", err);
298 }
299 },
300 },
950ef90Claude301 {
302 // Spec-to-PR autopilot — picks up `.gluecron/specs/*.md` files whose
303 // front-matter status is `ready`, asks Claude to implement the spec,
304 // opens a draft PR tagged `ai:spec-implementation`. Cadence-gated
305 // to every 2 minutes.
306 name: "spec-to-pr",
307 run: async () => {
308 if (!process.env.ANTHROPIC_API_KEY) return;
309 const now = Date.now();
310 if (now - _lastSpecToPrAt < SPEC_TO_PR_INTERVAL_MS) return;
311 _lastSpecToPrAt = now;
312 try {
313 const summary = await runSpecToPrTaskOnce();
314 console.log(
315 `[autopilot] spec-to-pr: considered=${summary.considered} dispatched=${summary.dispatched} skipped=${summary.skipped} failed=${summary.failed}`
316 );
317 } catch (err) {
318 console.error("[autopilot] spec-to-pr: threw:", err);
319 }
320 },
321 },
b1be050CC LABS App322 {
323 // BLOCK S4 — Synthetic monitor.
324 //
325 // Runs the URL-only smoke suite (see src/lib/synthetic-monitor.ts),
326 // records the outcome into `synthetic_checks`, and on a
327 // green->red transition fires a webhook to MONITOR_ALERT_WEBHOOK_URL
328 // (when configured) so the owner finds out instantly that the live
329 // site is broken. Wrapped in try/catch — the monitor must never
330 // wedge the tick.
331 name: "synthetic-monitor",
332 run: async () => {
333 try {
334 const summary = await runSyntheticMonitorTaskOnce();
335 console.log(
336 `[autopilot] synthetic-monitor: green=${summary.green} red=${summary.red} transitions=${summary.transitions}`
337 );
338 } catch (err) {
339 console.error("[autopilot] synthetic-monitor: threw:", err);
340 }
341 },
342 },
45f3b73Claude343 {
344 // Migration watcher — scans each repo's package.json for deps that
345 // are at least one major version behind and asks Claude to draft an
346 // upgrade PR. Cadence-gated to every 6 hours; the lib itself
347 // enforces a per-repo + per-{dep,version} 7-day dedupe so we never
348 // re-propose the same migration twice in a single window. Skips
349 // entirely when ANTHROPIC_API_KEY is unset OR the
350 // MIGRATION_WATCHER_ENABLED env flag is off.
351 name: "migration-watcher",
352 run: async () => {
353 if (!process.env.ANTHROPIC_API_KEY) return;
354 const now = Date.now();
355 if (now - _lastMigrationWatcherAt < MIGRATION_WATCHER_INTERVAL_MS) {
356 return;
357 }
358 _lastMigrationWatcherAt = now;
359 try {
360 const summary = await runMigrationWatcherTaskOnce();
361 console.log(
362 `[autopilot] migration-watcher: considered=${summary.considered} proposed=${summary.proposed} throttled=${summary.skippedThrottle} disabled=${summary.skippedNotEnabled} errors=${summary.errors}`
363 );
364 } catch (err) {
365 console.error("[autopilot] migration-watcher: threw:", err);
366 }
367 },
368 },
a686079Claude369 {
370 // AI Standup — daily Claude-generated team brief.
371 // Fires at the user's configured UTC hour (default 09:00). Skips
372 // entirely when ANTHROPIC_API_KEY is unset (the lib still has a
373 // deterministic fallback, but we keep this task quiet unless the
374 // operator has wired AI). Per-user dedupe via `hasStandupForToday`.
375 name: "daily-standup",
376 run: async () => {
377 if (!process.env.ANTHROPIC_API_KEY) return;
378 try {
379 const summary = await runDailyStandupTaskOnce();
380 console.log(
381 `[autopilot] daily-standup: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
382 );
383 } catch (err) {
384 console.error("[autopilot] daily-standup: threw:", err);
385 }
386 },
387 },
3c03977Claude388 {
389 // PR live co-editing — transition stale `pr_live_sessions` rows
390 // to 'idle' (>60s) and 'left' (>5m) so the presence pill on the
391 // PR detail page never claims a ghost user is still editing.
392 // Cheap pure-SQL UPDATE; runs every tick.
393 name: "pr-live-cleanup",
394 run: async () => {
395 try {
396 const summary = await sweepStalePrLive();
397 if (summary.idled > 0 || summary.left > 0) {
398 console.log(
399 `[autopilot] pr-live-cleanup: idled=${summary.idled} left=${summary.left}`
400 );
401 }
402 } catch (err) {
403 console.error("[autopilot] pr-live-cleanup: threw:", err);
404 }
405 },
406 },
a686079Claude407 {
408 // AI Standup — weekly Claude-generated team brief. Mondays only.
409 name: "weekly-standup",
410 run: async () => {
411 if (!process.env.ANTHROPIC_API_KEY) return;
412 try {
413 const summary = await runWeeklyStandupTaskOnce();
414 console.log(
415 `[autopilot] weekly-standup: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
416 );
417 } catch (err) {
418 console.error("[autopilot] weekly-standup: threw:", err);
419 }
420 },
421 },
2b821b7Claude422 ];
423}
424
b1be050CC LABS App425// ---------------------------------------------------------------------------
426// BLOCK S4 — synthetic-monitor task
427// ---------------------------------------------------------------------------
428
429export interface SyntheticMonitorTaskDeps {
430 /** Override the suite runner (DI for tests). */
431 runChecks?: () => Promise<SyntheticCheckResult[]>;
432 /** Override the persistence step (DI for tests). */
433 persist?: (results: SyntheticCheckResult[]) => Promise<void>;
434 /** Override the previous-state loader (DI for tests). */
435 loadPrevious?: () => Promise<Record<string, SyntheticCheckResult>>;
436 /** Override the webhook poster (DI for tests). */
437 postAlert?: (url: string, payload: unknown) => Promise<void>;
438 /** Override the alert-webhook URL lookup (defaults to env). */
439 alertUrl?: () => string;
440}
441
442export interface SyntheticMonitorTaskSummary {
443 green: number;
444 red: number;
445 yellow: number;
446 transitions: number;
447}
448
449async function defaultPostAlert(
450 url: string,
451 payload: unknown
452): Promise<void> {
453 try {
454 await fetch(url, {
455 method: "POST",
456 headers: { "Content-Type": "application/json" },
457 body: JSON.stringify(payload),
458 });
459 } catch (err) {
460 console.error("[autopilot] synthetic-monitor: alert webhook failed:", err);
461 }
462}
463
464/**
465 * One iteration of the synthetic-monitor task. Runs the checks, persists
466 * them, compares against the prior state, and fires a webhook on each
467 * green->red transition (red->red repeats stay quiet so we don't spam
468 * the channel). Never throws.
469 */
470export async function runSyntheticMonitorTaskOnce(
471 deps: SyntheticMonitorTaskDeps = {}
472): Promise<SyntheticMonitorTaskSummary> {
473 const runChecks = deps.runChecks ?? (() => runSyntheticChecks());
474 const persist = deps.persist ?? persistChecks;
475 const loadPrevious =
476 deps.loadPrevious ??
477 (async () => {
478 const latest = await latestStatusByCheck();
479 // Strip the `checkedAt` from the result shape so the diff loop
480 // compares the canonical SyntheticCheckResult fields only.
481 const out: Record<string, SyntheticCheckResult> = {};
482 for (const [k, v] of Object.entries(latest)) {
483 const { checkedAt: _unused, ...rest } = v;
484 void _unused;
485 out[k] = rest;
486 }
487 return out;
488 });
489 const postAlert = deps.postAlert ?? defaultPostAlert;
490 const alertUrl =
491 deps.alertUrl ?? (() => process.env.MONITOR_ALERT_WEBHOOK_URL || "");
492
493 let previous: Record<string, SyntheticCheckResult> = {};
494 try {
495 previous = await loadPrevious();
496 } catch (err) {
497 console.error(
498 "[autopilot] synthetic-monitor: loadPrevious threw:",
499 err
500 );
501 previous = {};
502 }
503
504 const results = await runChecks();
505 await persist(results);
506
507 let green = 0;
508 let red = 0;
509 let yellow = 0;
510 let transitions = 0;
511 const url = alertUrl();
512
513 for (const r of results) {
514 if (r.status === "green") green += 1;
515 else if (r.status === "red") red += 1;
516 else yellow += 1;
517
518 const prior = previous[r.name];
519 // green->red transition: prior was green (or absent and current is red
520 // after a green is also a transition — but absent-before is treated as
521 // green to avoid spamming on a fresh DB). We only alert on the
522 // green->red edge so red->red doesn't re-fire.
523 const priorWasGreen = !prior || prior.status === "green";
524 if (priorWasGreen && r.status === "red") {
525 transitions += 1;
526 if (url) {
527 await postAlert(url, {
528 check: r.name,
529 status: r.status,
530 statusCode: r.statusCode ?? null,
531 durationMs: r.durationMs,
532 error: r.error ?? null,
533 checkedAt: new Date().toISOString(),
534 });
535 }
536 }
537 }
538
539 return { green, red, yellow, transitions };
540}
541
46d6165Claude542// ---------------------------------------------------------------------------
543// L1 — sleep-mode-digest
544// ---------------------------------------------------------------------------
545
546export interface SleepModeDigestCandidate {
547 userId: string;
548 digestHourUtc: number;
549 lastDigestSentAt: Date | null;
550}
551
552export interface SleepModeDigestTaskDeps {
553 /** Override the candidate finder. */
554 findCandidates?: (cap: number) => Promise<SleepModeDigestCandidate[]>;
555 /** Override the send-one-user helper (DI for tests). */
556 sendOne?: (userId: string) => Promise<{ ok: boolean; reason?: string }>;
557 /** Override the wall clock (DI for tests). */
558 now?: () => Date;
559 /** Override the per-tick cap. */
560 cap?: number;
561 /** Override the cooldown hours. */
562 cooldownHours?: number;
563}
564
565export interface SleepModeDigestTaskSummary {
566 sent: number;
567 skipped: number;
568}
569
570/**
571 * Default candidate-finder. Returns enabled users whose
572 * `lastDigestSentAt` is older than the cooldown OR null. The hour-match
573 * filter is applied in JS by `runSleepModeDigestTaskOnce` so it stays
574 * timezone-independent of any SQL `extract(hour ...)` behaviour.
575 */
576async function defaultFindSleepModeCandidates(
577 cap: number
578): Promise<SleepModeDigestCandidate[]> {
579 try {
580 const rows = await db
581 .select({
582 userId: users.id,
583 digestHourUtc: users.sleepModeDigestHourUtc,
584 lastDigestSentAt: users.lastDigestSentAt,
585 })
586 .from(users)
587 .where(eq(users.sleepModeEnabled, true))
588 .limit(cap);
589 return rows.map((r) => ({
590 userId: r.userId,
591 digestHourUtc: r.digestHourUtc,
592 lastDigestSentAt: r.lastDigestSentAt,
593 }));
594 } catch (err) {
595 console.error("[autopilot] sleep-mode-digest: candidate query failed:", err);
596 return [];
597 }
598}
599
600/**
601 * One iteration of the sleep-mode-digest task. Never throws.
602 *
603 * Per-user filters (applied in JS so we can DI a clock):
604 * 1. `lastDigestSentAt` is null OR older than cooldown (23h).
605 * 2. `now.getUTCHours() === digestHourUtc` — fires once at the user's
606 * configured local UTC hour.
607 *
608 * Caps at `SLEEP_MODE_USER_CAP_PER_TICK` (100) users per tick.
609 */
610export async function runSleepModeDigestTaskOnce(
611 deps: SleepModeDigestTaskDeps = {}
612): Promise<SleepModeDigestTaskSummary> {
613 const findCandidates =
614 deps.findCandidates ?? defaultFindSleepModeCandidates;
615 const sendOne = deps.sendOne ?? sendSleepModeDigestForUser;
616 const now = deps.now ?? (() => new Date());
617 const cap = deps.cap ?? SLEEP_MODE_USER_CAP_PER_TICK;
618 const cooldownHours = deps.cooldownHours ?? SLEEP_MODE_COOLDOWN_HOURS;
619
620 let candidates: SleepModeDigestCandidate[] = [];
621 try {
622 candidates = await findCandidates(cap);
623 } catch (err) {
624 console.error("[autopilot] sleep-mode-digest: findCandidates threw:", err);
625 return { sent: 0, skipped: 0 };
626 }
627
628 const nowDate = now();
629 const currentHour = nowDate.getUTCHours();
630 const cooldownMs = cooldownHours * 60 * 60 * 1000;
631
632 let sent = 0;
633 let skipped = 0;
634
635 for (const cand of candidates) {
636 try {
637 // Hour-match: must equal the user's configured UTC delivery hour.
638 if (cand.digestHourUtc !== currentHour) {
639 skipped += 1;
640 continue;
641 }
642 // Cooldown: skip if we sent within the last cooldown window.
643 if (
644 cand.lastDigestSentAt &&
645 nowDate.getTime() - new Date(cand.lastDigestSentAt).getTime() <
646 cooldownMs
647 ) {
648 skipped += 1;
649 continue;
650 }
651 const result = await sendOne(cand.userId);
652 if (result.ok) sent += 1;
653 else skipped += 1;
654 } catch (err) {
655 skipped += 1;
656 console.error(
657 `[autopilot] sleep-mode-digest: per-user failure for user=${cand.userId}:`,
658 err
659 );
660 }
661 }
662
663 return { sent, skipped };
664}
665
534f04aClaude666// ---------------------------------------------------------------------------
667// M3 — pr-risk-rescore
668// ---------------------------------------------------------------------------
669
670export interface PrRiskRescoreCandidate {
671 pullRequestId: string;
672 headBranch: string;
673 updatedAt: Date;
674}
675
676export interface PrRiskRescoreTaskDeps {
677 /** Override candidate finder for tests. */
678 findCandidates?: (
679 lookbackHours: number,
680 cap: number
681 ) => Promise<PrRiskRescoreCandidate[]>;
682 /** Override score computation for tests. */
683 scoreOne?: (prId: string) => Promise<{ ok: boolean }>;
684 /** Override per-tick cap. */
685 cap?: number;
686 /** Override lookback. */
687 lookbackHours?: number;
688}
689
690export interface PrRiskRescoreTaskSummary {
691 scored: number;
692 skipped: number;
693}
694
695/**
696 * Default candidate-finder. Returns open, non-draft PRs from non-archived
697 * repos whose `updated_at` falls inside the lookback window. The "scored
698 * at all" filter is applied as a second pass via `defaultFilterNeedsScoring`.
699 */
700async function defaultFindPrRiskCandidates(
701 lookbackHours: number,
702 cap: number
703): Promise<PrRiskRescoreCandidate[]> {
704 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
705 try {
706 const rows = await db
707 .select({
708 pullRequestId: pullRequests.id,
709 headBranch: pullRequests.headBranch,
710 updatedAt: pullRequests.updatedAt,
711 })
712 .from(pullRequests)
713 .innerJoin(
714 repositories,
715 eq(repositories.id, pullRequests.repositoryId)
716 )
717 .where(
718 and(
719 eq(pullRequests.state, "open"),
720 eq(pullRequests.isDraft, false),
721 eq(repositories.isArchived, false),
722 gte(pullRequests.updatedAt, cutoff)
723 )
724 )
725 .orderBy(sql`${pullRequests.updatedAt} DESC`)
726 .limit(cap);
727 return rows.map((r) => ({
728 pullRequestId: r.pullRequestId,
729 headBranch: r.headBranch,
730 updatedAt: r.updatedAt,
731 }));
732 } catch (err) {
733 console.error("[autopilot] pr-risk-rescore: candidate query failed:", err);
734 return [];
735 }
736}
737
738/**
739 * Drop candidates that already have ANY cached score row. The unique
740 * constraint on (pull_request_id, commit_sha) handles the "score-the-
741 * same-SHA-twice" case at persist time; this filter just keeps the work
742 * list small enough to fit under the per-tick cap when many PRs are
743 * being pushed concurrently.
744 */
745async function defaultFilterNeedsScoring(
746 candidates: PrRiskRescoreCandidate[]
747): Promise<PrRiskRescoreCandidate[]> {
748 if (candidates.length === 0) return [];
749 try {
750 const rows = await db
751 .select({ pullRequestId: prRiskScores.pullRequestId })
752 .from(prRiskScores);
753 const scoredIds = new Set(rows.map((r) => r.pullRequestId));
754 return candidates.filter((c) => !scoredIds.has(c.pullRequestId));
755 } catch (err) {
756 console.error("[autopilot] pr-risk-rescore: filter query failed:", err);
757 // Fail-open: better to score everything than silently skip.
758 return candidates;
759 }
760}
761
762/**
763 * One iteration of the pr-risk-rescore task. Never throws. Compute risk
764 * for up to `cap` recently-touched open PRs that have no cached score
765 * yet, so reviewers usually see a populated card on first visit.
766 */
767export async function runPrRiskRescoreTaskOnce(
768 deps: PrRiskRescoreTaskDeps = {}
769): Promise<PrRiskRescoreTaskSummary> {
770 const findCandidates = deps.findCandidates ?? defaultFindPrRiskCandidates;
771 const scoreOne =
772 deps.scoreOne ??
773 (async (prId: string) => {
774 try {
775 const result = await computePrRiskForPullRequest(prId);
776 return { ok: result !== null };
777 } catch {
778 return { ok: false };
779 }
780 });
781 const cap = deps.cap ?? PR_RISK_RESCORE_MAX_PER_TICK;
782 const lookbackHours =
783 deps.lookbackHours ?? PR_RISK_RESCORE_LOOKBACK_HOURS;
784
785 let candidates: PrRiskRescoreCandidate[] = [];
786 try {
787 candidates = await findCandidates(lookbackHours, cap);
788 } catch (err) {
789 console.error("[autopilot] pr-risk-rescore: findCandidates threw:", err);
790 return { scored: 0, skipped: 0 };
791 }
792
793 // Only score PRs missing a cached row. Skip filter when the caller
794 // injected a custom finder (tests pass already-filtered lists).
795 const needsScoring =
796 deps.findCandidates === undefined
797 ? await defaultFilterNeedsScoring(candidates)
798 : candidates;
799
800 let scored = 0;
801 let skipped = 0;
802 for (const cand of needsScoring.slice(0, cap)) {
803 try {
804 const result = await scoreOne(cand.pullRequestId);
805 if (result.ok) scored += 1;
806 else skipped += 1;
807 } catch (err) {
808 skipped += 1;
809 console.error(
810 `[autopilot] pr-risk-rescore: per-PR failure for pr=${cand.pullRequestId}:`,
811 err
812 );
813 }
814 }
815 if (needsScoring.length > cap) {
816 skipped += needsScoring.length - cap;
817 }
818
819 return { scored, skipped };
820}
821
2b9055eClaude822// ---------------------------------------------------------------------------
823// K3 — auto-merge-sweep
824// ---------------------------------------------------------------------------
825
826interface SweepCandidate {
827 prId: string;
828 prNumber: number;
829 prTitle: string;
830 prBody: string | null;
831 baseBranch: string;
832 headBranch: string;
833 isDraft: boolean;
834 repositoryId: string;
835 authorUserId: string;
836 ownerUsername: string | null;
837 repoName: string;
838 state: string;
839}
840
841export interface AutoMergeSweepDeps {
842 /** Inject candidate-finder for tests. */
843 findCandidates?: (lookbackHours: number, limit: number) => Promise<SweepCandidate[]>;
844 /** Inject evaluator for tests. */
845 evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>;
846 /** Inject the merge executor for tests. */
847 merge?: (cand: SweepCandidate) => Promise<PerformMergeResult>;
848 /** Inject the audit-recording side-effect for tests. */
849 recordAttempt?: (
850 repoId: string,
851 prId: string,
852 decision: AutoMergeDecision
853 ) => Promise<void>;
854 /** Inject the audit/comment side-effects for the merged path (tests). */
855 onMerged?: (
856 cand: SweepCandidate,
857 result: PerformMergeResult
858 ) => Promise<void>;
859 /** Inject the audit side-effect for the merge-failed path (tests). */
860 onMergeFailed?: (cand: SweepCandidate, error: string) => Promise<void>;
861 /** Inject the AI-key short-circuit signal for tests. */
862 shouldShortCircuitAi?: (cand: SweepCandidate) => Promise<boolean>;
863}
864
865export interface AutoMergeSweepSummary {
866 evaluated: number;
867 merged: number;
868 blocked: number;
869}
870
871/**
872 * Default candidate-finder. Selects open, non-draft PRs from non-archived
873 * repos whose `updated_at` is within the lookback window. Joins repo +
874 * owner so the merge executor doesn't need extra round trips. Cap is
875 * enforced at the SQL layer.
876 */
877async function defaultFindAutoMergeCandidates(
878 lookbackHours: number,
879 limit: number
880): Promise<SweepCandidate[]> {
881 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
882 try {
883 const rows = await db
884 .select({
885 prId: pullRequests.id,
886 prNumber: pullRequests.number,
887 prTitle: pullRequests.title,
888 prBody: pullRequests.body,
889 baseBranch: pullRequests.baseBranch,
890 headBranch: pullRequests.headBranch,
891 isDraft: pullRequests.isDraft,
892 repositoryId: pullRequests.repositoryId,
893 authorUserId: pullRequests.authorId,
894 ownerUsername: users.username,
895 repoName: repositories.name,
896 state: pullRequests.state,
897 })
898 .from(pullRequests)
899 .innerJoin(
900 repositories,
901 eq(repositories.id, pullRequests.repositoryId)
902 )
903 .leftJoin(users, eq(users.id, repositories.ownerId))
904 .where(
905 and(
906 eq(pullRequests.state, "open"),
907 eq(pullRequests.isDraft, false),
908 eq(repositories.isArchived, false),
909 gte(pullRequests.updatedAt, cutoff)
910 )
911 )
912 .limit(limit);
913 return rows.map((r) => ({
914 prId: r.prId,
915 prNumber: r.prNumber,
916 prTitle: r.prTitle,
917 prBody: r.prBody,
918 baseBranch: r.baseBranch,
919 headBranch: r.headBranch,
920 isDraft: r.isDraft,
921 repositoryId: r.repositoryId,
922 authorUserId: r.authorUserId,
923 ownerUsername: r.ownerUsername ?? null,
924 repoName: r.repoName,
925 state: r.state,
926 }));
927 } catch (err) {
928 console.error("[autopilot] auto-merge: candidate query failed:", err);
929 return [];
930 }
931}
932
933/**
934 * Determine whether the matched branch_protection rule on this PR
935 * requires AI approval but no `ANTHROPIC_API_KEY` is configured. In that
936 * case the AI-approval check would inevitably fail downstream, so we
937 * short-circuit to a "blocked" decision without invoking `evaluateAutoMerge`
938 * — keeps the log readable and prevents misleading "AI review unavailable"
939 * lines in the audit trail.
940 */
941async function defaultShouldShortCircuitAi(
942 cand: SweepCandidate
943): Promise<boolean> {
944 if (process.env.ANTHROPIC_API_KEY) return false;
945 try {
946 const rule = await matchProtection(cand.repositoryId, cand.baseBranch);
947 return !!(rule && rule.requireAiApproval);
948 } catch {
949 return false;
950 }
951}
952
953/**
954 * Default success-path: post an `auto_merge.merged` audit row + a stable
955 * marker comment on the PR so a partial-merge retry doesn't double-post.
956 * Both are best-effort; failures are logged not thrown.
957 */
958async function defaultOnMerged(
959 cand: SweepCandidate,
960 result: PerformMergeResult
961): Promise<void> {
962 try {
963 await audit({
964 repositoryId: cand.repositoryId,
965 action: "auto_merge.merged",
966 targetType: "pull_request",
967 targetId: cand.prId,
968 metadata: {
969 prNumber: cand.prNumber,
970 baseBranch: cand.baseBranch,
971 headBranch: cand.headBranch,
972 closedIssueNumbers: result.closedIssueNumbers,
973 resolvedFiles: result.resolvedFiles,
974 },
975 });
976 } catch (err) {
977 console.error("[autopilot] auto-merge: merged audit failed:", err);
978 }
979 try {
980 await db.insert(prComments).values({
981 pullRequestId: cand.prId,
982 authorId: cand.authorUserId,
983 isAiReview: true,
984 body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
985 });
986 } catch (err) {
987 console.error("[autopilot] auto-merge: comment insert failed:", err);
988 }
989}
990
991/** Default failure-path: only an audit row; no comment (we may retry). */
992async function defaultOnMergeFailed(
993 cand: SweepCandidate,
994 error: string
995): Promise<void> {
996 try {
997 await audit({
998 repositoryId: cand.repositoryId,
999 action: "auto_merge.merge_failed",
1000 targetType: "pull_request",
1001 targetId: cand.prId,
1002 metadata: {
1003 prNumber: cand.prNumber,
1004 baseBranch: cand.baseBranch,
1005 headBranch: cand.headBranch,
1006 error,
1007 },
1008 });
1009 } catch (err) {
1010 console.error("[autopilot] auto-merge: merge_failed audit failed:", err);
1011 }
1012}
1013
1014/**
1015 * Execute one sweep over recently-updated open PRs. For each, evaluate
1016 * with K2's `evaluateAutoMerge`; on `merge: true`, call `performMerge` and
1017 * record the merged/merge-failed audit row + comment. Always record the
1018 * `auto_merge.evaluated` audit row via `recordAutoMergeAttempt`.
1019 *
1020 * Returns a counts summary that the autopilot prints as the tick log line.
1021 * Never throws.
1022 */
1023export async function runAutoMergeSweep(
1024 deps: AutoMergeSweepDeps = {}
1025): Promise<AutoMergeSweepSummary> {
1026 const findCandidates = deps.findCandidates ?? defaultFindAutoMergeCandidates;
1027 const evaluate =
1028 deps.evaluate ?? ((ctx) => evaluateAutoMerge(ctx, {}));
1029 const merge =
1030 deps.merge ??
1031 (async (cand) => {
1032 if (!cand.ownerUsername) {
1033 return {
1034 ok: false,
1035 error: "owner username unresolved",
1036 closedIssueNumbers: [],
1037 resolvedFiles: [],
1038 };
1039 }
1040 return performMerge({
1041 pr: {
1042 id: cand.prId,
1043 number: cand.prNumber,
1044 title: cand.prTitle,
1045 body: cand.prBody,
1046 baseBranch: cand.baseBranch,
1047 headBranch: cand.headBranch,
1048 repositoryId: cand.repositoryId,
1049 authorId: cand.authorUserId,
1050 state: cand.state as "open",
1051 isDraft: cand.isDraft,
1052 },
1053 ownerName: cand.ownerUsername,
1054 repoName: cand.repoName,
1055 actorUserId: cand.authorUserId,
1056 });
1057 });
1058 const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt;
1059 const onMerged = deps.onMerged ?? defaultOnMerged;
1060 const onMergeFailed = deps.onMergeFailed ?? defaultOnMergeFailed;
1061 const shouldShortCircuitAi =
1062 deps.shouldShortCircuitAi ?? defaultShouldShortCircuitAi;
1063
1064 let candidates: SweepCandidate[] = [];
1065 try {
1066 candidates = await findCandidates(
1067 AUTO_MERGE_LOOKBACK_HOURS,
1068 AUTO_MERGE_MAX_PER_TICK
1069 );
1070 } catch (err) {
1071 console.error("[autopilot] auto-merge: findCandidates threw:", err);
1072 return { evaluated: 0, merged: 0, blocked: 0 };
1073 }
1074
1075 let evaluated = 0;
1076 let merged = 0;
1077 let blocked = 0;
1078
1079 for (const cand of candidates) {
1080 try {
1081 evaluated += 1;
1082
1083 // AI-key short-circuit: if the rule requires AI approval and we have
1084 // no key, treat as blocked without calling the evaluator (which would
1085 // log a misleading "AI review unavailable").
1086 let decision: AutoMergeDecision;
1087 if (await shouldShortCircuitAi(cand)) {
1088 decision = {
1089 merge: false,
1090 reason:
1091 "Branch protection requires AI approval but ANTHROPIC_API_KEY is unset.",
1092 blocking: [
1093 "ANTHROPIC_API_KEY missing; AI approval cannot be sourced.",
1094 ],
1095 };
1096 } else {
1097 decision = await evaluate({
1098 pullRequestId: cand.prId,
1099 repositoryId: cand.repositoryId,
1100 baseBranch: cand.baseBranch,
1101 isDraft: cand.isDraft,
1102 authorUserId: cand.authorUserId,
1103 });
1104 }
1105
1106 // Always record the evaluation, regardless of outcome.
1107 try {
1108 await recordAttempt(cand.repositoryId, cand.prId, decision);
1109 } catch (err) {
1110 console.error(
1111 `[autopilot] auto-merge: recordAttempt failed for pr=${cand.prId}:`,
1112 err
1113 );
1114 }
1115
1116 if (!decision.merge) {
1117 blocked += 1;
1118 continue;
1119 }
1120
1121 // Perform the actual merge.
1122 const result = await merge(cand);
1123 if (result.ok) {
1124 merged += 1;
1125 await onMerged(cand, result);
1126 } else {
1127 blocked += 1;
1128 await onMergeFailed(cand, result.error || "unknown merge error");
1129 }
1130 } catch (err) {
1131 blocked += 1;
1132 console.error(
1133 `[autopilot] auto-merge: per-PR failure for pr=${cand.prId}:`,
1134 err
1135 );
1136 }
1137 }
1138
1139 console.log(
1140 `[autopilot] auto-merge: evaluated=${evaluated} merged=${merged} blocked=${blocked}`
1141 );
1142
1143 return { evaluated, merged, blocked };
1144}
1145
2b821b7Claude1146/**
1147 * Visits each distinct (repo, base_branch) that has queued rows and logs a
1148 * stub depth line. The actual gate-running + merge happens in the pulls
1149 * route; this tick is just a heartbeat so we can wire per-queue progress
1150 * through without duplicating merge logic.
1151 */
1152async function processMergeQueues(): Promise<void> {
1153 let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
1154 try {
1155 const rows = await db
1156 .selectDistinct({
1157 repositoryId: mergeQueueEntries.repositoryId,
1158 baseBranch: mergeQueueEntries.baseBranch,
1159 })
1160 .from(mergeQueueEntries)
1161 .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
1162 distinct = rows;
1163 } catch (err) {
1164 console.error("[autopilot] merge-queue: distinct query failed:", err);
1165 return;
1166 }
1167 for (const d of distinct) {
1168 try {
1169 const head = await peekHead(d.repositoryId, d.baseBranch);
1170 if (head) {
1171 console.log(
1172 `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
1173 );
1174 }
1175 } catch (err) {
1176 console.error(
1177 `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
1178 err
1179 );
1180 }
1181 }
1182}
1183
1184/**
1185 * Pick a small batch of repos that actually have dep rows and re-run
1186 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
1187 */
1188async function rescanAdvisoriesBatch(limit: number): Promise<void> {
1189 let repoIds: string[] = [];
1190 try {
1191 const rows = await db
1192 .selectDistinct({ repositoryId: repoDependencies.repositoryId })
1193 .from(repoDependencies)
1194 .limit(limit);
1195 repoIds = rows.map((r) => r.repositoryId);
1196 } catch (err) {
1197 console.error("[autopilot] advisory-rescan: query failed:", err);
1198 return;
1199 }
1200 for (const id of repoIds) {
1201 try {
1202 await scanRepositoryForAlerts(id);
1203 } catch (err) {
1204 console.error(
1205 `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
1206 err
1207 );
1208 }
1209 }
1210}
1211
1212/** Resolve the tick interval from env → opts → default. */
1213function resolveIntervalMs(optsMs?: number): number {
1214 if (typeof optsMs === "number" && optsMs > 0) return optsMs;
1215 const raw = process.env.AUTOPILOT_INTERVAL_MS;
1216 if (raw) {
1217 const parsed = Number(raw);
1218 if (Number.isFinite(parsed) && parsed > 0) return parsed;
1219 }
1220 return DEFAULT_INTERVAL_MS;
1221}
1222
1223/**
1224 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
1225 * The first tick fires after `intervalMs`, not immediately, to keep boot
1226 * fast. Returns a `stop()` that clears the interval.
1227 */
1228export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
1229 if (process.env.AUTOPILOT_DISABLED === "1") {
1230 return { stop: () => {} };
1231 }
1232 const intervalMs = resolveIntervalMs(opts?.intervalMs);
1233 const tasks = opts?.tasks ?? defaultTasks();
1234 let running = false;
1235 const handle = setInterval(() => {
1236 if (running) return;
1237 running = true;
1238 void runAutopilotTick({ tasks, now: opts?.now })
1239 .catch(() => {
1240 // runAutopilotTick already never throws, but belt-and-braces.
1241 })
1242 .finally(() => {
1243 running = false;
1244 });
1245 }, intervalMs);
1246 return {
1247 stop: () => clearInterval(handle),
1248 };
1249}
1250
8e9f1d9Claude1251/** Last tick snapshot for observability. Module-level, swap-on-complete. */
1252let lastTick: AutopilotTickResult | null = null;
1253let tickCount = 0;
1254
1255/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
1256export function getLastTick(): AutopilotTickResult | null {
1257 return lastTick;
1258}
1259
1260/** Return the total number of completed ticks in this process. */
1261export function getTickCount(): number {
1262 return tickCount;
1263}
1264
2b821b7Claude1265/**
1266 * Run one tick: invokes every sub-task with its own try/catch, records a
1267 * per-task result, and emits a single summary line. Never throws.
1268 */
1269export async function runAutopilotTick(
1270 opts?: RunTickOpts
1271): Promise<AutopilotTickResult> {
1272 const now = opts?.now ?? Date.now;
1273 const tasks = opts?.tasks ?? defaultTasks();
1274 const startedAt = new Date(now()).toISOString();
1275 const results: AutopilotTaskResult[] = [];
1276 for (const t of tasks) {
1277 const t0 = now();
1278 try {
1279 await t.run();
1280 results.push({ name: t.name, ok: true, durationMs: now() - t0 });
1281 } catch (err) {
1282 const message =
1283 err instanceof Error ? err.message : String(err ?? "unknown error");
1284 console.error(`[autopilot] ${t.name}: ${message}`);
1285 results.push({
1286 name: t.name,
1287 ok: false,
1288 durationMs: now() - t0,
1289 error: message,
1290 });
1291 }
1292 }
1293 const finishedAt = new Date(now()).toISOString();
1294 const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
1295 const okCount = results.filter((r) => r.ok).length;
1296 console.log(
1297 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
1298 );
8e9f1d9Claude1299 const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
1300 lastTick = result;
1301 tickCount += 1;
1302 return result;
2b821b7Claude1303}
1304
1305/** Exposed for unit tests. */
1306export const __test = {
1307 resolveIntervalMs,
1308 processMergeQueues,
1309 rescanAdvisoriesBatch,
1310 DEFAULT_INTERVAL_MS,
1311 ADVISORY_RESCAN_BATCH,
2b9055eClaude1312 AUTO_MERGE_LOOKBACK_HOURS,
1313 AUTO_MERGE_MAX_PER_TICK,
1314 AUTO_MERGE_COMMENT_MARKER,
534f04aClaude1315 PR_RISK_RESCORE_MAX_PER_TICK,
1316 PR_RISK_RESCORE_LOOKBACK_HOURS,
2b9055eClaude1317 defaultFindAutoMergeCandidates,
1318 defaultOnMerged,
1319 defaultOnMergeFailed,
1320 defaultShouldShortCircuitAi,
534f04aClaude1321 defaultFindPrRiskCandidates,
1322 defaultFilterNeedsScoring,
2b821b7Claude1323};