Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

sleep-mode.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.

sleep-mode.tsBlame499 lines · 1 contributor
46d6165Claude1/**
2 * Block L1 — Sleep Mode.
3 *
4 * Pitch: "Toggle Sleep Mode. Walk away. Wake up to a digest of what Claude
5 * shipped overnight."
6 *
7 * Sleep Mode is a per-user toggle (`users.sleep_mode_enabled`) that, when on:
8 * 1. Bumps the email digest cadence from weekly → daily.
9 * 2. Reframes the digest as "what AI did in the last 24h" — PRs auto-merged
10 * by the K3 autopilot, issues built from `ai:build` labels, AI reviews
11 * posted, AI security scans, and gate failures that auto-repair fixed.
12 * 3. Fires at the user-configured UTC hour (`sleep_mode_digest_hour_utc`,
13 * default 9).
14 *
15 * Re-uses `sendEmail` from `src/lib/email.ts` and the locked `escapeHtml`
16 * helper from `src/lib/email-digest.ts`. Does NOT modify either module —
17 * Block L1 is strictly additive.
18 *
19 * Contract: every exported function NEVER throws. Failures are logged and
20 * surface as either zero-valued reports or `{ok:false, reason}` results.
21 */
22
23import { and, eq, gte, inArray, sql } from "drizzle-orm";
24import { db } from "../db";
25import {
26 auditLog,
27 gateRuns,
28 issueComments,
29 issues,
30 prComments,
31 pullRequests,
32 repositories,
33 users,
34} from "../db/schema";
35import { sendEmail, type EmailResult } from "./email";
36import { config } from "./config";
37import { __internal as digestInternals } from "./email-digest";
38import { AI_BUILD_MARKER } from "./ai-build-tasks";
c315551Claude39import { computeHoursSaved } from "./ai-hours-saved";
46d6165Claude40
41const { escapeHtml } = digestInternals;
42
43/** Default look-back window. Sleep Mode is a daily digest. */
44const DEFAULT_WINDOW_HOURS = 24;
45/** Per-tick cap on users we'll email — runaway protection. */
46export const SLEEP_MODE_USER_CAP_PER_TICK = 100;
47/** Cooldown anchored to `users.last_digest_sent_at`. */
48export const SLEEP_MODE_COOLDOWN_HOURS = 23;
49
50export type SleepModeReport = {
51 windowHours: number;
52 prsAutoMerged: { number: number; title: string; repo: string }[];
53 issuesBuiltByAi: { number: number; title: string; repo: string; prNumber?: number }[];
54 aiReviewsPosted: number;
55 securityIssuesAutoFixed: number;
56 gateFailuresAutoRepaired: number;
57 /** Derived. Rounded to one decimal. See heuristic constants above. */
58 hoursSaved: number;
59};
60
61
62/**
63 * Compose a Sleep Mode report for one user. Pulls from:
64 * - audit_log `auto_merge.merged` → prsAutoMerged
65 * - issue_comments matching `AI_BUILD_MARKER` on this user's repos → issuesBuiltByAi
66 * - pr_comments where is_ai_review=true on this user's repos → aiReviewsPosted
67 * - gate_runs status='repaired' AND gate_name LIKE '%Secret%' → securityIssuesAutoFixed
68 * - gate_runs status='repaired' (others) → gateFailuresAutoRepaired
69 *
70 * Never throws. On DB error, returns a zero-valued report.
71 */
72export async function composeSleepModeReport(
73 userId: string,
74 opts?: { sinceHoursAgo?: number; now?: Date }
75): Promise<SleepModeReport> {
76 const windowHours = opts?.sinceHoursAgo ?? DEFAULT_WINDOW_HOURS;
77 const now = opts?.now ?? new Date();
78 const since = new Date(now.getTime() - windowHours * 60 * 60 * 1000);
79
80 const empty: SleepModeReport = {
81 windowHours,
82 prsAutoMerged: [],
83 issuesBuiltByAi: [],
84 aiReviewsPosted: 0,
85 securityIssuesAutoFixed: 0,
86 gateFailuresAutoRepaired: 0,
87 hoursSaved: 0,
88 };
89
90 try {
91 // Resolve the user's repos (owner only — orgs aren't user-toggled).
92 const ownedRepos = await db
93 .select({ id: repositories.id, name: repositories.name })
94 .from(repositories)
95 .where(eq(repositories.ownerId, userId));
96 if (ownedRepos.length === 0) return empty;
97
98 const repoIds = ownedRepos.map((r) => r.id);
99 const repoNameById = new Map(ownedRepos.map((r) => [r.id, r.name]));
100
101 // -----------------------------------------------------------------
102 // PRs auto-merged: audit_log `auto_merge.merged` in window.
103 // Join PR for title+number.
104 // -----------------------------------------------------------------
105 let prsAutoMerged: SleepModeReport["prsAutoMerged"] = [];
106 try {
107 const rows = await db
108 .select({
109 targetId: auditLog.targetId,
110 repositoryId: auditLog.repositoryId,
111 })
112 .from(auditLog)
113 .where(
114 and(
115 eq(auditLog.action, "auto_merge.merged"),
116 inArray(auditLog.repositoryId, repoIds),
117 gte(auditLog.createdAt, since)
118 )
119 )
120 .limit(50);
121 const prIds = rows
122 .map((r) => r.targetId)
123 .filter((v): v is string => typeof v === "string" && v.length > 0);
124 if (prIds.length > 0) {
125 const prRows = await db
126 .select({
127 id: pullRequests.id,
128 number: pullRequests.number,
129 title: pullRequests.title,
130 repositoryId: pullRequests.repositoryId,
131 })
132 .from(pullRequests)
133 .where(inArray(pullRequests.id, prIds));
134 prsAutoMerged = prRows.map((p) => ({
135 number: p.number,
136 title: p.title,
137 repo: repoNameById.get(p.repositoryId) || "?",
138 }));
139 }
140 } catch (err) {
141 console.error("[sleep-mode] prsAutoMerged query failed:", err);
142 }
143
144 // -----------------------------------------------------------------
145 // Issues built by AI: issue_comments whose body includes the
146 // K3 AI-build marker, within the window, on repos this user owns.
147 // -----------------------------------------------------------------
148 let issuesBuiltByAi: SleepModeReport["issuesBuiltByAi"] = [];
149 try {
150 const rows = await db
151 .select({
152 issueId: issueComments.issueId,
153 createdAt: issueComments.createdAt,
154 })
155 .from(issueComments)
156 .innerJoin(issues, eq(issues.id, issueComments.issueId))
157 .where(
158 and(
159 inArray(issues.repositoryId, repoIds),
160 gte(issueComments.createdAt, since),
161 sql`${issueComments.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}`
162 )
163 )
164 .limit(50);
165 const issueIds = Array.from(new Set(rows.map((r) => r.issueId)));
166 if (issueIds.length > 0) {
167 const issueRows = await db
168 .select({
169 id: issues.id,
170 number: issues.number,
171 title: issues.title,
172 repositoryId: issues.repositoryId,
173 })
174 .from(issues)
175 .where(inArray(issues.id, issueIds));
176 issuesBuiltByAi = issueRows.map((i) => ({
177 number: i.number,
178 title: i.title,
179 repo: repoNameById.get(i.repositoryId) || "?",
180 }));
181 }
182 } catch (err) {
183 console.error("[sleep-mode] issuesBuiltByAi query failed:", err);
184 }
185
186 // -----------------------------------------------------------------
187 // AI reviews posted on this user's repos in the window.
188 // pr_comments.is_ai_review=true, joined to pullRequests for repo
189 // filter. We count, we don't list — the digest just reports the
190 // total. (Per-PR list would balloon the email.)
191 // -----------------------------------------------------------------
192 let aiReviewsPosted = 0;
193 try {
194 const rows = await db
195 .select({ c: sql<number>`count(*)::int` })
196 .from(prComments)
197 .innerJoin(
198 pullRequests,
199 eq(pullRequests.id, prComments.pullRequestId)
200 )
201 .where(
202 and(
203 eq(prComments.isAiReview, true),
204 inArray(pullRequests.repositoryId, repoIds),
205 gte(prComments.createdAt, since)
206 )
207 );
208 aiReviewsPosted = Number(rows[0]?.c ?? 0);
209 } catch (err) {
210 console.error("[sleep-mode] aiReviewsPosted query failed:", err);
211 }
212
213 // -----------------------------------------------------------------
214 // Gate auto-repairs in window. Split into "security" (secret-scan
215 // gates) and "everything else" (test repair, etc).
216 // -----------------------------------------------------------------
217 let securityIssuesAutoFixed = 0;
218 let gateFailuresAutoRepaired = 0;
219 try {
220 const rows = await db
221 .select({
222 gateName: gateRuns.gateName,
223 })
224 .from(gateRuns)
225 .where(
226 and(
227 inArray(gateRuns.repositoryId, repoIds),
228 eq(gateRuns.status, "repaired"),
229 gte(gateRuns.createdAt, since)
230 )
231 )
232 .limit(500);
233 for (const r of rows) {
234 const lower = (r.gateName || "").toLowerCase();
235 if (lower.includes("secret") || lower.includes("security")) {
236 securityIssuesAutoFixed += 1;
237 } else {
238 gateFailuresAutoRepaired += 1;
239 }
240 }
241 } catch (err) {
242 console.error("[sleep-mode] gate-runs query failed:", err);
243 }
244
245 const hoursSaved = computeHoursSaved({
246 prsAutoMerged: prsAutoMerged.length,
247 issuesBuiltByAi: issuesBuiltByAi.length,
248 aiReviewsPosted,
c315551Claude249 aiTriagesPosted: 0,
250 aiCommitMsgs: 0,
251 secretsAutoRepaired: securityIssuesAutoFixed,
252 gateAutoRepairs: gateFailuresAutoRepaired,
46d6165Claude253 });
254
255 return {
256 windowHours,
257 prsAutoMerged,
258 issuesBuiltByAi,
259 aiReviewsPosted,
260 securityIssuesAutoFixed,
261 gateFailuresAutoRepaired,
262 hoursSaved,
263 };
264 } catch (err) {
265 console.error("[sleep-mode] composeSleepModeReport error:", err);
266 return empty;
267 }
268}
269
270/**
271 * Render a Sleep Mode digest. Returns `{subject, text, html}` ready to hand
272 * off to `sendEmail`. Pure — no DB, no env, no I/O.
273 *
274 * HTML escaping is handled here for every user-controlled value so a
275 * crafted PR/issue title cannot break out and inject script. The plaintext
276 * branch is plaintext by definition (consumers display as-is).
277 */
278export function renderSleepModeDigest(
279 report: SleepModeReport,
280 opts: { username: string }
281): { subject: string; text: string; html: string } {
282 const username = opts.username;
283 const base = config.appBaseUrl || "https://gluecron.com";
284 const total =
285 report.prsAutoMerged.length +
286 report.issuesBuiltByAi.length +
287 report.aiReviewsPosted +
288 report.securityIssuesAutoFixed +
289 report.gateFailuresAutoRepaired;
290
291 const subject =
292 total === 0
293 ? `Sleep Mode: a quiet night on Gluecron`
294 : `Sleep Mode: while you slept, Claude shipped ${total} thing${total === 1 ? "" : "s"}`;
295
296 // ---- Plaintext ----
297 const textLines: string[] = [];
298 textLines.push(`Hi ${username},`);
299 textLines.push("");
300 if (total === 0) {
301 textLines.push(
302 `Nothing fired in the last ${report.windowHours} hours. Quiet night.`
303 );
304 } else {
305 textLines.push(
306 `While you slept, Claude opened/merged ${report.prsAutoMerged.length} PR${report.prsAutoMerged.length === 1 ? "" : "s"}, built ${report.issuesBuiltByAi.length} issue${report.issuesBuiltByAi.length === 1 ? "" : "s"}, posted ${report.aiReviewsPosted} AI review${report.aiReviewsPosted === 1 ? "" : "s"}, fixed ${report.securityIssuesAutoFixed} security issue${report.securityIssuesAutoFixed === 1 ? "" : "s"}, and auto-repaired ${report.gateFailuresAutoRepaired} gate failure${report.gateFailuresAutoRepaired === 1 ? "" : "s"}.`
307 );
308 textLines.push("");
309 textLines.push(`Estimated time saved: ${report.hoursSaved} hours.`);
310 }
311 textLines.push("");
312
313 if (report.prsAutoMerged.length > 0) {
314 textLines.push("## PRs auto-merged");
315 for (const pr of report.prsAutoMerged.slice(0, 10)) {
316 textLines.push(`- ${pr.repo} #${pr.number} ${pr.title}`);
317 }
318 textLines.push("");
319 }
320 if (report.issuesBuiltByAi.length > 0) {
321 textLines.push("## Issues built by AI");
322 for (const it of report.issuesBuiltByAi.slice(0, 10)) {
323 const tail = it.prNumber ? ` -> PR #${it.prNumber}` : "";
324 textLines.push(`- ${it.repo} #${it.number} ${it.title}${tail}`);
325 }
326 textLines.push("");
327 }
328 if (
329 report.aiReviewsPosted +
330 report.securityIssuesAutoFixed +
331 report.gateFailuresAutoRepaired >
332 0
333 ) {
334 textLines.push("## Automated guardrails");
335 textLines.push(`- AI reviews posted: ${report.aiReviewsPosted}`);
336 textLines.push(
337 `- Security issues auto-fixed: ${report.securityIssuesAutoFixed}`
338 );
339 textLines.push(
340 `- Gate failures auto-repaired: ${report.gateFailuresAutoRepaired}`
341 );
342 textLines.push("");
343 }
344
345 textLines.push("---");
346 textLines.push(
347 `Sleep Mode delivers this daily. Toggle off at ${base}/settings.`
348 );
349 const text = textLines.join("\n");
350
351 // ---- HTML ----
352 // We do NOT route through email-digest's textToHtml because the Sleep
353 // Mode digest has a custom hero block + bespoke styling. We DO use the
354 // shared `escapeHtml` for every user-supplied string.
355 const html = renderHtml(report, { username, base, total });
356
357 return { subject, text, html };
358}
359
360function renderHtml(
361 report: SleepModeReport,
362 ctx: { username: string; base: string; total: number }
363): string {
364 const u = escapeHtml(ctx.username);
365 const heroLine =
366 ctx.total === 0
367 ? `Nothing fired in the last ${report.windowHours} hours. Quiet night.`
368 : `While you slept, Claude opened/merged <strong>${report.prsAutoMerged.length}</strong> PRs, built <strong>${report.issuesBuiltByAi.length}</strong> issues, posted <strong>${report.aiReviewsPosted}</strong> AI reviews, fixed <strong>${report.securityIssuesAutoFixed}</strong> security issues, and auto-repaired <strong>${report.gateFailuresAutoRepaired}</strong> gate failures.`;
369
370 const parts: string[] = [];
371 parts.push(
372 `<html><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#0f1019;background:#fff">`
373 );
374 parts.push(
375 `<div style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;padding:24px;border-radius:12px;margin-bottom:24px">` +
376 `<div style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase;opacity:0.85">Sleep Mode</div>` +
377 `<h1 style="margin:8px 0 0;font-size:22px;font-weight:600">Good morning, ${u}.</h1>` +
378 `<p style="margin:12px 0 0;font-size:14px;line-height:1.5;opacity:0.95">${heroLine}</p>` +
379 (ctx.total > 0
380 ? `<p style="margin:12px 0 0;font-size:14px;font-weight:600">Estimated time saved: ${report.hoursSaved} hours</p>`
381 : "") +
382 `</div>`
383 );
384
385 if (report.prsAutoMerged.length > 0) {
386 parts.push(
387 `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">PRs auto-merged</h3>`
388 );
389 parts.push(`<ul style="padding-left:18px;margin:8px 0">`);
390 for (const pr of report.prsAutoMerged.slice(0, 10)) {
391 parts.push(
392 `<li><strong>${escapeHtml(pr.repo)}</strong> #${pr.number} ${escapeHtml(pr.title)}</li>`
393 );
394 }
395 parts.push(`</ul>`);
396 }
397 if (report.issuesBuiltByAi.length > 0) {
398 parts.push(
399 `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">Issues built by AI</h3>`
400 );
401 parts.push(`<ul style="padding-left:18px;margin:8px 0">`);
402 for (const it of report.issuesBuiltByAi.slice(0, 10)) {
403 const tail = it.prNumber ? ` &rarr; PR #${it.prNumber}` : "";
404 parts.push(
405 `<li><strong>${escapeHtml(it.repo)}</strong> #${it.number} ${escapeHtml(it.title)}${tail}</li>`
406 );
407 }
408 parts.push(`</ul>`);
409 }
410 if (
411 report.aiReviewsPosted +
412 report.securityIssuesAutoFixed +
413 report.gateFailuresAutoRepaired >
414 0
415 ) {
416 parts.push(
417 `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px;font-size:14px">Automated guardrails</h3>`
418 );
419 parts.push(`<ul style="padding-left:18px;margin:8px 0">`);
420 parts.push(`<li>AI reviews posted: ${report.aiReviewsPosted}</li>`);
421 parts.push(
422 `<li>Security issues auto-fixed: ${report.securityIssuesAutoFixed}</li>`
423 );
424 parts.push(
425 `<li>Gate failures auto-repaired: ${report.gateFailuresAutoRepaired}</li>`
426 );
427 parts.push(`</ul>`);
428 }
429
430 parts.push(
431 `<hr style="border:none;border-top:1px solid #eee;margin:24px 0" />`
432 );
433 parts.push(
434 `<p style="font-size:12px;color:#777">Sleep Mode delivers this daily. Toggle off at <a href="${escapeHtml(ctx.base)}/settings">${escapeHtml(ctx.base)}/settings</a>.</p>`
435 );
436 parts.push(`</body></html>`);
437 return parts.join("\n");
438}
439
440/**
441 * Compose + send a Sleep Mode digest for one user. Stamps
442 * `users.last_digest_sent_at` on success so the autopilot cooldown holds
443 * for 23h. Never throws.
444 *
445 * Skips (returns `{ok:false, reason}`) when:
446 * - user not found
447 * - user.sleepModeEnabled is false
448 * - composer returns the zero report AND owner has no repos (graceful)
449 * -> we DO still send if they have repos but a quiet window.
450 */
451export async function sendSleepModeDigestForUser(
452 userId: string
453): Promise<{ ok: boolean; reason?: string }> {
454 try {
455 const [user] = await db
456 .select()
457 .from(users)
458 .where(eq(users.id, userId))
459 .limit(1);
460 if (!user) return { ok: false, reason: "user not found" };
461 if (!user.sleepModeEnabled) return { ok: false, reason: "sleep mode off" };
462
463 const report = await composeSleepModeReport(userId);
464 const { subject, text, html } = renderSleepModeDigest(report, {
465 username: user.username,
466 });
467
468 const result: EmailResult = await sendEmail({
469 to: user.email,
470 subject,
471 text,
472 html,
473 });
474 if (result.ok) {
475 try {
476 await db
477 .update(users)
e1fc7dbClaude478 .set({ lastSleepDigestSentAt: new Date() })
46d6165Claude479 .where(eq(users.id, userId));
480 } catch (err) {
e1fc7dbClaude481 console.error("[sleep-mode] lastSleepDigestSentAt update failed:", err);
46d6165Claude482 }
483 return { ok: true };
484 }
485 return {
486 ok: false,
487 reason: result.skipped || result.error || "send failed",
488 };
489 } catch (err) {
490 console.error("[sleep-mode] sendSleepModeDigestForUser error:", err);
491 return { ok: false, reason: "error" };
492 }
493}
494
495/** Test-only surface. */
496export const __test = {
497 DEFAULT_WINDOW_HOURS,
498 renderHtml,
499};