Commita76d984unknown_key
feat(env): enforce environment wait timer (no longer a stub)
feat(env): enforce environment wait timer (no longer a stub)
Previously environments.wait_timer_minutes was stored but never honoured —
approval flipped status straight to "pending" no matter how large the
configured timer was. This pass turns it on:
Schema:
- drizzle/0038_deployment_ready_after.sql adds deployments.ready_after
(timestamptz, NULL = no wait). Strictly additive; existing rows
unaffected. Partial index covers the autopilot sweep.
Pure helpers (src/lib/environments.ts):
- latestApprovalAt(decided) — newest approved-decision timestamp,
null when no approvals (or all
rejections / malformed dates).
- computeReadyAfter(env, decided) — latestApprovalAt + minutes*60s,
or null when timer<=0 / no approvals.
- computeApprovalState now returns readyAfter alongside approved/rejected.
Approval route (src/routes/environments.tsx):
- On approval, when readyAfter > now() the deployment goes to
status="waiting_timer" with deployments.ready_after set, plus a
human-readable blocked_reason ("wait_timer until <iso>").
- When readyAfter <= now (or no timer), legacy behaviour: flip to
"pending" immediately so the existing deployer picks up the job.
- ready_after is overwritten on every flip — never stale.
Sweeper:
- src/lib/environments.ts releaseExpiredWaitTimers(now=new Date())
scans status="waiting_timer" + ready_after <= now and flips them to
"pending". Best-effort, never throws (DB hiccup → returns 0).
Autopilot (src/lib/autopilot.ts):
- New default task "wait-timer-release" that calls the sweeper every
tick (5-min default). AUTOPILOT_DISABLED=1 still opts the whole
loop out; no other tasks are touched.
Tests: +12 (4 latestApprovalAt edge cases, 5 computeReadyAfter
including NaN/empty/multi-approval ordering, 1 releaseExpiredWaitTimers
fail-open, plus the existing 16 env tests still passing). Total suite
1083 / 8 skip / 0 fail (was 1073 / 8 / 0).
https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU6 files changed+258−8a76d984278e5e42c4f0de06e25c0d95ec9e22ddf
6 changed files+258−8
Addeddrizzle/0038_deployment_ready_after.sql+24−0View fileUnifiedSplit
@@ -0,0 +1,24 @@
1-- Deployment wait-timer enforcement (Block C4 follow-up).
2--
3-- Until now `environments.wait_timer_minutes` was stored but never enforced —
4-- approved deploys flipped to status="pending" the moment the last approval
5-- landed. This column unblocks real wait-timer semantics:
6--
7-- ready_after IS NULL → no wait; deploy may run as soon as it is "pending".
8-- ready_after > now() → deploy is "waiting_timer"; autopilot flips it to
9-- "pending" once the timer elapses.
10-- ready_after <= now() → deploy is ready (autopilot or any reader can flip).
11--
12-- Strictly additive — no existing rows touched, default is NULL so legacy
13-- deployments behave exactly as before.
14
15ALTER TABLE "deployments"
16 ADD COLUMN IF NOT EXISTS "ready_after" timestamptz;
17
18--> statement-breakpoint
19
20-- Partial index covers the autopilot sweep query
21-- (status='waiting_timer' AND ready_after <= now()).
22CREATE INDEX IF NOT EXISTS "deployments_ready_after"
23 ON "deployments" ("ready_after")
24 WHERE "ready_after" IS NOT NULL;
Modifiedsrc/__tests__/environments.test.ts+101−0View fileUnifiedSplit
@@ -15,6 +15,9 @@ import {
1515 reduceApprovalState,
1616 reviewerIdsOf,
1717 allowedBranchesOf,
18 latestApprovalAt,
19 computeReadyAfter,
20 releaseExpiredWaitTimers,
1821} from "../lib/environments";
1922import type { Environment, DeploymentApproval } from "../db/schema";
2023
@@ -194,3 +197,101 @@ describe("environments routes — unauthed guards", () => {
194197 expect([401, 404, 503]).toContain(res.status);
195198 });
196199});
200
201// ---------------------------------------------------------------------------
202// Wait-timer enforcement (no longer a stub)
203// ---------------------------------------------------------------------------
204
205const mkApprovalAt = (
206 decision: "approved" | "rejected",
207 createdAt: Date,
208 userId = "u1"
209): DeploymentApproval =>
210 ({
211 id: `a-${Math.random()}`,
212 deploymentId: "d1",
213 userId,
214 decision,
215 comment: null,
216 createdAt,
217 }) as DeploymentApproval;
218
219describe("latestApprovalAt", () => {
220 it("returns null when there are no approvals", () => {
221 expect(latestApprovalAt([])).toBeNull();
222 });
223
224 it("ignores rejection timestamps", () => {
225 const t1 = new Date("2026-01-01T00:00:00Z");
226 expect(latestApprovalAt([mkApprovalAt("rejected", t1)])).toBeNull();
227 });
228
229 it("returns the most recent approval timestamp", () => {
230 const t1 = new Date("2026-01-01T00:00:00Z");
231 const t2 = new Date("2026-01-02T00:00:00Z");
232 const t3 = new Date("2026-01-03T00:00:00Z");
233 const out = latestApprovalAt([
234 mkApprovalAt("approved", t2, "u1"),
235 mkApprovalAt("approved", t3, "u2"),
236 mkApprovalAt("approved", t1, "u3"),
237 ]);
238 expect(out?.toISOString()).toBe(t3.toISOString());
239 });
240
241 it("tolerates a malformed createdAt", () => {
242 const out = latestApprovalAt([
243 {
244 ...mkApprovalAt("approved", new Date("2026-01-01T00:00:00Z")),
245 createdAt: new Date("not-a-real-date"),
246 } as any,
247 ]);
248 expect(out).toBeNull();
249 });
250});
251
252describe("computeReadyAfter", () => {
253 it("returns null when waitTimerMinutes <= 0", () => {
254 const env = envFixture({ waitTimerMinutes: 0 });
255 const t = new Date("2026-01-01T00:00:00Z");
256 expect(computeReadyAfter(env, [mkApprovalAt("approved", t)])).toBeNull();
257 });
258
259 it("returns null when there are no approvals (timer hasn't started)", () => {
260 const env = envFixture({ waitTimerMinutes: 30 });
261 expect(computeReadyAfter(env, [])).toBeNull();
262 });
263
264 it("adds waitTimerMinutes to the latest approval timestamp", () => {
265 const env = envFixture({ waitTimerMinutes: 30 });
266 const t = new Date("2026-01-01T12:00:00Z");
267 const ready = computeReadyAfter(env, [mkApprovalAt("approved", t)]);
268 expect(ready?.toISOString()).toBe("2026-01-01T12:30:00.000Z");
269 });
270
271 it("uses the latest approval, not the earliest", () => {
272 const env = envFixture({ waitTimerMinutes: 60 });
273 const t1 = new Date("2026-01-01T00:00:00Z");
274 const t2 = new Date("2026-01-01T01:00:00Z");
275 const ready = computeReadyAfter(env, [
276 mkApprovalAt("approved", t1, "u1"),
277 mkApprovalAt("approved", t2, "u2"),
278 ]);
279 expect(ready?.toISOString()).toBe("2026-01-01T02:00:00.000Z");
280 });
281
282 it("returns null on a malformed waitTimerMinutes value", () => {
283 const env = envFixture({ waitTimerMinutes: NaN as any });
284 const t = new Date("2026-01-01T00:00:00Z");
285 expect(computeReadyAfter(env, [mkApprovalAt("approved", t)])).toBeNull();
286 });
287});
288
289describe("releaseExpiredWaitTimers — fail-open", () => {
290 it("returns 0 (not throws) when DB is unavailable / no rows match", async () => {
291 // No live DB → drizzle will throw inside the helper, which catches and
292 // returns 0. Either way the test asserts the never-throw contract.
293 const out = await releaseExpiredWaitTimers(new Date());
294 expect(typeof out).toBe("number");
295 expect(out).toBeGreaterThanOrEqual(0);
296 });
297});
Modifiedsrc/db/schema.ts+7−1View fileUnifiedSplit
@@ -397,10 +397,16 @@ export const deployments = pgTable(
397397 environment: text("environment").notNull().default("production"),
398398 commitSha: text("commit_sha").notNull(),
399399 ref: text("ref").notNull(),
400 status: text("status").notNull(), // pending, running, success, failed, blocked
400 status: text("status").notNull(), // pending, running, success, failed, blocked, waiting_timer
401401 blockedReason: text("blocked_reason"),
402402 target: text("target"), // e.g. "crontech", "fly.io"
403403 triggeredBy: uuid("triggered_by").references(() => users.id),
404 /**
405 * Set when an approved deploy is held by `environments.wait_timer_minutes`.
406 * NULL = no wait; non-null = autopilot flips status from "waiting_timer"
407 * to "pending" once `now() >= ready_after`.
408 */
409 readyAfter: timestamp("ready_after"),
404410 createdAt: timestamp("created_at").defaultNow().notNull(),
405411 completedAt: timestamp("completed_at"),
406412 },
Modifiedsrc/lib/autopilot.ts+7−0View fileUnifiedSplit
@@ -16,6 +16,7 @@ import { syncAllDue } from "./mirrors";
1616import { peekHead } from "./merge-queue";
1717import { sendDigestsToAll } from "./email-digest";
1818import { scanRepositoryForAlerts } from "./advisories";
19import { releaseExpiredWaitTimers } from "./environments";
1920
2021export interface AutopilotTaskResult {
2122 name: string;
@@ -79,6 +80,12 @@ export function defaultTasks(): AutopilotTask[] {
7980 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
8081 },
8182 },
83 {
84 name: "wait-timer-release",
85 run: async () => {
86 await releaseExpiredWaitTimers();
87 },
88 },
8289 ];
8390}
8491
Modifiedsrc/lib/environments.ts+100−4View fileUnifiedSplit
@@ -7,17 +7,20 @@
77 * - if `reviewers` is empty, the repo owner is treated as the implicit reviewer.
88 * - `allowedBranches` is a JSON array of glob patterns. When non-empty only
99 * refs matching at least one pattern may deploy through the environment.
10 * - `waitTimerMinutes` is stored but NOT enforced in v1 (stub).
10 * - `waitTimerMinutes` is now enforced: approval flips status to
11 * "waiting_timer" + populates `deployments.ready_after`; the autopilot
12 * ticker sweeps timers that have elapsed and flips them to "pending".
1113 *
1214 * All DB calls are wrapped in try/catch so the caller gets well-defined
1315 * shapes even when the database is unreachable (keeps the hot push path safe).
1416 */
1517
16import { and, desc, eq } from "drizzle-orm";
18import { and, desc, eq, isNotNull, lte, sql } from "drizzle-orm";
1719import { db } from "../db";
1820import {
1921 environments,
2022 deploymentApprovals,
23 deployments,
2124 repositories,
2225} from "../db/schema";
2326import type { Environment, DeploymentApproval } from "../db/schema";
@@ -226,20 +229,70 @@ export function reduceApprovalState(decided: DeploymentApproval[]): {
226229 return { approved, rejected, decided };
227230}
228231
232/**
233 * Pure helper — return the timestamp of the most recent "approved"
234 * decision, or null if there are no approvals. Used as the basis for
235 * wait-timer enforcement.
236 */
237export function latestApprovalAt(
238 decided: DeploymentApproval[]
239): Date | null {
240 let latest: Date | null = null;
241 for (const d of decided) {
242 if (d.decision !== "approved") continue;
243 const t = d.createdAt ? new Date(d.createdAt) : null;
244 if (!t || isNaN(t.getTime())) continue;
245 if (!latest || t.getTime() > latest.getTime()) latest = t;
246 }
247 return latest;
248}
249
250/**
251 * Pure helper — given an environment + the current approvals + a "now"
252 * reference, return the Date when the deploy may proceed, or null if
253 * either the env has no wait timer (waitTimerMinutes <= 0) or there are
254 * no approvals yet. Returning a past timestamp is deliberate — caller
255 * can compare `readyAfter <= now` to decide whether to skip the
256 * "waiting_timer" state entirely.
257 */
258export function computeReadyAfter(
259 env: Pick<Environment, "waitTimerMinutes">,
260 decided: DeploymentApproval[]
261): Date | null {
262 const minutes = Number(env.waitTimerMinutes || 0);
263 if (!Number.isFinite(minutes) || minutes <= 0) return null;
264 const last = latestApprovalAt(decided);
265 if (!last) return null;
266 return new Date(last.getTime() + minutes * 60_000);
267}
268
229269/**
230270 * v1 semantics: approved = at least one approval exists and no rejection.
231271 * rejected = at least one rejection exists.
272 *
273 * waitTimerMinutes enforcement: when the env carries a positive timer
274 * the response includes `readyAfter` — the wall-clock the deployer
275 * should not run before. Callers compare against `now`:
276 * - readyAfter == null → proceed immediately on approval
277 * - readyAfter <= now → proceed (timer already elapsed)
278 * - readyAfter > now → set deployment.status="waiting_timer" and
279 * deployment.readyAfter=readyAfter, then let
280 * the autopilot ticker (`releaseExpiredWaitTimers`)
281 * flip it to "pending" once the wall passes.
232282 */
233283export async function computeApprovalState(
234284 deploymentId: string,
235 _env: Environment
285 env: Environment
236286): Promise<{
237287 approved: boolean;
238288 rejected: boolean;
239289 decided: DeploymentApproval[];
290 readyAfter: Date | null;
240291}> {
241292 const decided = await listApprovals(deploymentId);
242 return reduceApprovalState(decided);
293 const base = reduceApprovalState(decided);
294 const readyAfter = base.approved ? computeReadyAfter(env, decided) : null;
295 return { ...base, readyAfter };
243296}
244297
245298/**
@@ -302,3 +355,46 @@ export async function requiresApprovalFor(
302355 if (env.requireApproval) return { required: true, env };
303356 return { required: false, env };
304357}
358
359// ---------------------------------------------------------------------------
360// Wait-timer sweeper
361// ---------------------------------------------------------------------------
362
363/**
364 * Flip every "waiting_timer" deployment whose `ready_after` has passed to
365 * "pending" so the deployer picks it up. Called from the autopilot ticker.
366 *
367 * Returns the number of deployments released. Best-effort — DB hiccups
368 * resolve to `0` rather than throw, so the autopilot loop never wedges.
369 */
370export async function releaseExpiredWaitTimers(
371 now: Date = new Date()
372): Promise<number> {
373 try {
374 const rows = await db
375 .update(deployments)
376 .set({ status: "pending" })
377 .where(
378 and(
379 eq(deployments.status, "waiting_timer"),
380 isNotNull(deployments.readyAfter),
381 lte(deployments.readyAfter, sql`${now.toISOString()}::timestamptz`)
382 )
383 )
384 .returning({ id: deployments.id });
385 return rows ? rows.length : 0;
386 } catch (err) {
387 console.error("[environments] releaseExpiredWaitTimers failed:", err);
388 return 0;
389 }
390}
391
392/**
393 * Test-only: pure helpers + the sweeper exposed without DB. The sweeper is
394 * already async-DB; we mainly want the pure helpers reachable for tests
395 * that don't have access to a Postgres.
396 */
397export const __test = {
398 latestApprovalAt,
399 computeReadyAfter,
400};
Modifiedsrc/routes/environments.tsx+19−3View fileUnifiedSplit
@@ -527,13 +527,26 @@ async function decide(
527527 comment,
528528 });
529529
530 // Re-read state and flip the deployment row accordingly.
530 // Re-read state and flip the deployment row accordingly. When the env
531 // carries a wait timer and the timer hasn't elapsed yet, we hold the
532 // deploy in status="waiting_timer" with `readyAfter` populated; the
533 // autopilot ticker (`releaseExpiredWaitTimers`) flips it later.
531534 const state = await computeApprovalState(deploymentId, env);
532535 let newStatus: string | null = null;
536 let readyAfter: Date | null = null;
537 let blockedReason: string | null = null;
533538 if (state.rejected) {
534539 newStatus = "rejected";
540 blockedReason = "rejected by reviewer";
535541 } else if (state.approved && deployment.status === "pending_approval") {
536 newStatus = "pending"; // hand off to existing deployer
542 const now = new Date();
543 if (state.readyAfter && state.readyAfter.getTime() > now.getTime()) {
544 newStatus = "waiting_timer";
545 readyAfter = state.readyAfter;
546 blockedReason = `wait_timer until ${state.readyAfter.toISOString()}`;
547 } else {
548 newStatus = "pending"; // hand off to existing deployer
549 }
537550 }
538551
539552 if (newStatus) {
@@ -542,7 +555,10 @@ async function decide(
542555 .update(deployments)
543556 .set({
544557 status: newStatus,
545 blockedReason: newStatus === "rejected" ? "rejected by reviewer" : null,
558 blockedReason,
559 // Always overwrite readyAfter — clears any prior value when the
560 // status flips to anything other than waiting_timer.
561 readyAfter,
546562 })
547563 .where(eq(deployments.id, deploymentId));
548564 } catch (err) {
549565