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