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

events.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.

events.tsBlame1033 lines · 2 contributors
9e1e93aClaude1/**
2 * Inbound deploy-event receiver for Crontech (Signal Bus P1 — E3/E4).
3 *
4 * Wire contract reference: chat-defined spec for Crontech → Gluecron deploy
5 * events. Gluecron's OWN copy per HTTP-only coupling rule — do NOT import any
6 * types from Crontech. If the contract is renegotiated, update this comment
7 * and the validation below in lock-step.
8 *
9 * POST /api/events/deploy
10 * Authorization: Bearer ${CRONTECH_EVENT_TOKEN}
11 * Content-Type: application/json
12 *
13 * {
14 * "event": "deploy.succeeded" | "deploy.failed",
15 * "eventId": "<uuid-v4>", // idempotency key
16 * "repository": "owner/name",
17 * "sha": "<40-hex>",
18 * "environment": "production",
19 * "deploymentId": "<crontech-id>",
20 * "durationMs": <int>, // optional
21 * "errorCategory": "build|runtime|timeout|config", // required on failed
22 * "errorSummary": "<string ≤500>", // required on failed
23 * "logsUrl": "<string>", // optional
24 * "timestamp": "<ISO-8601>"
25 * }
26 *
27 * → 200 { ok: true, duplicate: false }
28 * → 200 { ok: true, duplicate: true }
29 * → 401 invalid bearer
30 * → 400 malformed payload
31 *
32 * Idempotency: an incoming `eventId` is first looked up in `processed_events`.
33 * On hit we return { duplicate: true } immediately — no side-effects. On miss
34 * we INSERT the idempotency record BEFORE performing the side-effect update so
35 * that a retry after a crash between steps sees the record and short-circuits.
36 *
37 * Side-effect: look up the matching `deployments` row by
38 * (repository_id, commit_sha, environment) — `deployments` has no
39 * `crontech_deployment_id` column so we key off the tuple that
40 * `triggerCrontechDeploy` writes on the way out.
41 *
42 * E3 deploy.succeeded → status='success', completedAt=now
43 * E4 deploy.failed → status='failed', blockedReason=errorSummary,
44 * completedAt=now, notify(owner, 'deploy_failed')
45 */
46
47import { Hono } from "hono";
48import { and, desc, eq } from "drizzle-orm";
49import { timingSafeEqual } from "crypto";
50import { db } from "../db";
51import { deployments, repositories, users } from "../db/schema";
52import { processedEvents } from "../db/schema-events";
9dd96b9Test User53import {
54 platformDeploys,
55 platformDeploySteps,
56} from "../db/schema-deploys";
57import { sql } from "drizzle-orm";
9e1e93aClaude58import { notify } from "../lib/notify";
f764c07Claude59import { publish } from "../lib/sse";
9e1e93aClaude60
61const events = new Hono();
62
63// ---------------------------------------------------------------------------
64// Bearer auth — timing-safe comparison against CRONTECH_EVENT_TOKEN.
65// ---------------------------------------------------------------------------
66
67function constantTimeEq(a: string, b: string): boolean {
68 const A = Buffer.from(a);
69 const B = Buffer.from(b);
70 if (A.length !== B.length) return false;
71 try {
72 return timingSafeEqual(A, B);
73 } catch {
74 return false;
75 }
76}
77
78function verifyBearer(c: any): { ok: boolean; error?: string } {
79 const expected = process.env.CRONTECH_EVENT_TOKEN || "";
80 if (!expected) {
81 // Refuse by default — an unset secret must NOT allow anonymous writes.
82 return {
83 ok: false,
84 error:
85 "Event endpoint not configured: set CRONTECH_EVENT_TOKEN in the environment",
86 };
87 }
88 const auth = c.req.header("authorization") || "";
89 if (!auth.startsWith("Bearer ")) {
90 return { ok: false, error: "Missing Bearer token" };
91 }
92 const token = auth.slice(7).trim();
93 if (!constantTimeEq(token, expected)) {
94 return { ok: false, error: "Invalid bearer token" };
95 }
96 return { ok: true };
97}
98
99// ---------------------------------------------------------------------------
100// Payload validation — no zod in this repo, use manual checks mirroring the
101// existing hooks.ts style. Keep error messages specific enough to diagnose a
102// mis-built emitter without leaking internals.
103// ---------------------------------------------------------------------------
104
105type DeployEvent = "deploy.succeeded" | "deploy.failed";
106type ErrorCategory = "build" | "runtime" | "timeout" | "config";
107
108interface DeployEventPayload {
109 event: DeployEvent;
110 eventId: string;
111 repository: string;
112 sha: string;
113 environment: string;
114 deploymentId: string;
115 durationMs?: number;
116 errorCategory?: ErrorCategory;
117 errorSummary?: string;
118 logsUrl?: string;
119 timestamp: string;
120}
121
122const UUID_V4_RE =
123 /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
124const SHA_RE = /^[0-9a-f]{40}$/i;
125const VALID_EVENTS: ReadonlySet<string> = new Set([
126 "deploy.succeeded",
127 "deploy.failed",
128]);
129const VALID_CATEGORIES: ReadonlySet<string> = new Set([
130 "build",
131 "runtime",
132 "timeout",
133 "config",
134]);
135
136function validatePayload(raw: unknown): {
137 ok: true;
138 payload: DeployEventPayload;
139} | { ok: false; error: string } {
140 if (!raw || typeof raw !== "object") {
141 return { ok: false, error: "Body must be a JSON object" };
142 }
143 const p = raw as Record<string, unknown>;
144
145 if (typeof p.event !== "string" || !VALID_EVENTS.has(p.event)) {
146 return {
147 ok: false,
148 error: "event must be 'deploy.succeeded' or 'deploy.failed'",
149 };
150 }
151 if (typeof p.eventId !== "string" || !UUID_V4_RE.test(p.eventId)) {
152 return { ok: false, error: "eventId must be a uuid-v4 string" };
153 }
154 if (typeof p.repository !== "string" || !p.repository.includes("/")) {
155 return { ok: false, error: "repository must be '<owner>/<name>'" };
156 }
157 if (typeof p.sha !== "string" || !SHA_RE.test(p.sha)) {
158 return { ok: false, error: "sha must be a 40-hex commit id" };
159 }
160 if (typeof p.environment !== "string" || p.environment.length === 0) {
161 return { ok: false, error: "environment must be a non-empty string" };
162 }
163 if (typeof p.deploymentId !== "string" || p.deploymentId.length === 0) {
164 return { ok: false, error: "deploymentId must be a non-empty string" };
165 }
166 if (typeof p.timestamp !== "string" || Number.isNaN(Date.parse(p.timestamp))) {
167 return { ok: false, error: "timestamp must be an ISO-8601 string" };
168 }
169 if (p.durationMs !== undefined) {
170 if (
171 typeof p.durationMs !== "number" ||
172 !Number.isFinite(p.durationMs) ||
173 p.durationMs < 0
174 ) {
175 return { ok: false, error: "durationMs must be a non-negative number" };
176 }
177 }
178 if (p.logsUrl !== undefined && typeof p.logsUrl !== "string") {
179 return { ok: false, error: "logsUrl must be a string when present" };
180 }
181
182 if (p.event === "deploy.failed") {
183 if (
184 typeof p.errorCategory !== "string" ||
185 !VALID_CATEGORIES.has(p.errorCategory)
186 ) {
187 return {
188 ok: false,
189 error:
190 "errorCategory is required for deploy.failed and must be one of build|runtime|timeout|config",
191 };
192 }
193 if (
194 typeof p.errorSummary !== "string" ||
195 p.errorSummary.length === 0 ||
196 p.errorSummary.length > 500
197 ) {
198 return {
199 ok: false,
200 error:
201 "errorSummary is required for deploy.failed and must be 1-500 chars",
202 };
203 }
204 }
205
206 return { ok: true, payload: p as unknown as DeployEventPayload };
207}
208
209// ---------------------------------------------------------------------------
210// Helpers
211// ---------------------------------------------------------------------------
212
213async function resolveRepo(
214 full: string
215): Promise<{ id: string; ownerId: string } | null> {
216 if (!full.includes("/")) return null;
217 const [owner, name] = full.split("/", 2);
218 try {
219 const [row] = await db
220 .select({
221 id: repositories.id,
222 ownerId: repositories.ownerId,
223 })
224 .from(repositories)
225 .innerJoin(users, eq(repositories.ownerId, users.id))
226 .where(and(eq(users.username, owner), eq(repositories.name, name)))
227 .limit(1);
228 return row || null;
229 } catch {
230 return null;
231 }
232}
233
234async function findTargetDeployment(
235 repositoryId: string,
236 commitSha: string,
237 environment: string
238): Promise<{ id: string } | null> {
239 try {
240 const [row] = await db
241 .select({ id: deployments.id })
242 .from(deployments)
243 .where(
244 and(
245 eq(deployments.repositoryId, repositoryId),
246 eq(deployments.commitSha, commitSha),
247 eq(deployments.environment, environment)
248 )
249 )
250 .orderBy(desc(deployments.createdAt))
251 .limit(1);
252 return row || null;
253 } catch {
254 return null;
255 }
256}
257
258// ---------------------------------------------------------------------------
259// POST /api/events/deploy
260// ---------------------------------------------------------------------------
261
262events.post("/deploy", async (c) => {
263 const auth = verifyBearer(c);
264 if (!auth.ok) {
265 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
266 }
267
268 let raw: unknown;
269 try {
270 raw = await c.req.json();
271 } catch {
272 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
273 }
274
275 const validated = validatePayload(raw);
276 if (!validated.ok) {
277 return c.json({ ok: false, error: validated.error }, 400);
278 }
279 const payload = validated.payload;
280
281 // --- Idempotency check ---------------------------------------------------
282 // If we've already processed this eventId, return duplicate:true without
283 // firing any side-effects.
284 try {
285 const [existing] = await db
286 .select({ id: processedEvents.id })
287 .from(processedEvents)
288 .where(eq(processedEvents.eventId, payload.eventId))
289 .limit(1);
290 if (existing) {
291 return c.json({ ok: true, duplicate: true });
292 }
293 } catch (err) {
294 console.error("[events/deploy] idempotency lookup failed:", err);
295 // Fall through — better to process than to wedge on a transient DB blip.
296 }
297
298 // --- Record the idempotency token BEFORE side-effects --------------------
299 // Race: two simultaneous deliveries of the same eventId. The UNIQUE
300 // constraint on event_id makes the losing insert throw; we catch that and
301 // return duplicate:true to keep behaviour stable.
302 try {
303 await db.insert(processedEvents).values({
304 eventId: payload.eventId,
305 eventType: payload.event,
306 source: "crontech",
307 payload: payload as unknown as Record<string, unknown>,
308 });
309 } catch (err) {
310 const msg = err instanceof Error ? err.message : String(err);
311 if (msg.includes("unique") || msg.includes("duplicate")) {
312 return c.json({ ok: true, duplicate: true });
313 }
314 console.error("[events/deploy] processed_events insert failed:", err);
315 return c.json({ ok: false, error: "Failed to persist event" }, 500);
316 }
317
318 // --- Side-effect: update the matching deployments row --------------------
319 const repo = await resolveRepo(payload.repository);
320 if (!repo) {
321 // The idempotency row is already committed; we accept the event so we
322 // don't invite infinite retries for a repo that genuinely doesn't exist
323 // on this side of the wire. Log for operator follow-up.
324 console.warn(
325 `[events/deploy] unknown repository ${payload.repository} — event ${payload.eventId} accepted but no deployment update applied`
326 );
327 return c.json({ ok: true, duplicate: false });
328 }
329
330 const target = await findTargetDeployment(
331 repo.id,
332 payload.sha,
333 payload.environment
334 );
335
336 if (target) {
337 try {
338 if (payload.event === "deploy.succeeded") {
339 await db
340 .update(deployments)
341 .set({
342 status: "success",
343 completedAt: new Date(),
344 })
345 .where(eq(deployments.id, target.id));
346 } else {
347 await db
348 .update(deployments)
349 .set({
350 status: "failed",
351 blockedReason: payload.errorSummary,
352 completedAt: new Date(),
353 })
354 .where(eq(deployments.id, target.id));
355 }
356 } catch (err) {
357 console.error("[events/deploy] deployments update failed:", err);
358 }
359 } else {
360 console.warn(
361 `[events/deploy] no deployments row found for ${payload.repository}@${payload.sha} env=${payload.environment}`
362 );
363 }
364
365 // --- On failure: notify the repo owner ----------------------------------
366 if (payload.event === "deploy.failed") {
367 try {
368 await notify(repo.ownerId, {
369 kind: "deploy_failed",
370 title: `Deploy failed on ${payload.repository}`,
371 body:
372 payload.errorSummary ||
373 `Crontech reported deploy failure (${payload.errorCategory || "unknown"})`,
374 url: payload.logsUrl,
375 repositoryId: repo.id,
376 });
377 } catch (err) {
378 console.error("[events/deploy] notify failed:", err);
379 }
380 }
381
382 return c.json({ ok: true, duplicate: false });
383});
384
f764c07Claude385// ---------------------------------------------------------------------------
386// Block N3 — Platform deploy timeline ingest.
387//
388// These endpoints are FOR THIS SITE. The Hetzner deploy workflow posts a
389// 'started' event when SSH begins and a 'finished' event on success/failure.
390// They power the admin status pill in `src/views/layout.tsx` and the
391// `/admin/deploys` timeline. NEW endpoints — they do NOT touch the Crontech
392// `/deploy` receiver above (which §4.6 locks the semantics of).
393//
394// POST /api/events/deploy/started
395// Authorization: Bearer ${DEPLOY_EVENT_TOKEN}
396// Body: { sha: "<40-hex>", run_id: "<string>", source: "<string>" }
397// 200 { ok: true, duplicate: false | true }
398// 401 invalid bearer
399// 400 malformed payload
400//
401// POST /api/events/deploy/finished
402// Authorization: Bearer ${DEPLOY_EVENT_TOKEN}
403// Body: { run_id, status: "succeeded"|"failed",
404// duration_ms?: number, error?: string }
405//
406// Idempotency: keyed on run_id via the UNIQUE constraint in migration 0046.
407// A duplicate 'started' POST is a no-op. A 'finished' POST without a prior
408// 'started' INSERTs a fresh row (so the timeline still records the deploy
409// even if the started-step silently dropped its packet).
410// ---------------------------------------------------------------------------
411
412const SHORT_SHA_RE = /^[0-9a-f]{7,64}$/i;
413const VALID_DEPLOY_STATUS: ReadonlySet<string> = new Set([
414 "succeeded",
415 "failed",
416]);
417
418function verifyDeployBearer(c: any): { ok: boolean; error?: string } {
419 const expected = process.env.DEPLOY_EVENT_TOKEN || "";
420 if (!expected) {
421 return {
422 ok: false,
423 error:
424 "Deploy event endpoint not configured: set DEPLOY_EVENT_TOKEN in the environment",
425 };
426 }
427 const auth = c.req.header("authorization") || "";
428 if (!auth.startsWith("Bearer ")) {
429 return { ok: false, error: "Missing Bearer token" };
430 }
431 const token = auth.slice(7).trim();
432 if (!constantTimeEq(token, expected)) {
433 return { ok: false, error: "Invalid bearer token" };
434 }
435 return { ok: true };
436}
437
438interface DeployStartedPayload {
439 sha: string;
440 run_id: string;
441 source: string;
442}
443
444interface DeployFinishedPayload {
445 run_id: string;
446 sha?: string;
447 status: "succeeded" | "failed";
448 duration_ms?: number;
449 error?: string;
450}
451
452function validateStarted(raw: unknown):
453 | { ok: true; payload: DeployStartedPayload }
454 | { ok: false; error: string } {
455 if (!raw || typeof raw !== "object") {
456 return { ok: false, error: "Body must be a JSON object" };
457 }
458 const p = raw as Record<string, unknown>;
459 if (typeof p.sha !== "string" || !SHORT_SHA_RE.test(p.sha)) {
460 return { ok: false, error: "sha must be a hex commit id (7-64 chars)" };
461 }
462 if (typeof p.run_id !== "string" || p.run_id.length === 0 || p.run_id.length > 128) {
463 return { ok: false, error: "run_id must be a non-empty string (≤128 chars)" };
464 }
465 if (typeof p.source !== "string" || p.source.length === 0 || p.source.length > 64) {
466 return { ok: false, error: "source must be a non-empty string (≤64 chars)" };
467 }
468 return {
469 ok: true,
470 payload: { sha: p.sha, run_id: p.run_id, source: p.source },
471 };
472}
473
474function validateFinished(raw: unknown):
475 | { ok: true; payload: DeployFinishedPayload }
476 | { ok: false; error: string } {
477 if (!raw || typeof raw !== "object") {
478 return { ok: false, error: "Body must be a JSON object" };
479 }
480 const p = raw as Record<string, unknown>;
481 if (typeof p.run_id !== "string" || p.run_id.length === 0 || p.run_id.length > 128) {
482 return { ok: false, error: "run_id must be a non-empty string (≤128 chars)" };
483 }
484 if (typeof p.status !== "string" || !VALID_DEPLOY_STATUS.has(p.status)) {
485 return {
486 ok: false,
487 error: "status must be 'succeeded' or 'failed'",
488 };
489 }
490 if (p.sha !== undefined && (typeof p.sha !== "string" || !SHORT_SHA_RE.test(p.sha))) {
491 return { ok: false, error: "sha must be a hex commit id when provided" };
492 }
493 if (p.duration_ms !== undefined) {
494 if (
495 typeof p.duration_ms !== "number" ||
496 !Number.isFinite(p.duration_ms) ||
497 p.duration_ms < 0
498 ) {
499 return { ok: false, error: "duration_ms must be a non-negative number" };
500 }
501 }
502 if (p.error !== undefined && typeof p.error !== "string") {
503 return { ok: false, error: "error must be a string when provided" };
504 }
505 return {
506 ok: true,
507 payload: {
508 run_id: p.run_id,
509 sha: typeof p.sha === "string" ? p.sha : undefined,
510 status: p.status as "succeeded" | "failed",
511 duration_ms:
512 typeof p.duration_ms === "number" ? p.duration_ms : undefined,
513 // Cap error text at 8 KB so a misbehaving emitter can't blow up
514 // the DB row (the workflow already truncates to 1 KB on its end).
515 error:
516 typeof p.error === "string" ? p.error.slice(0, 8 * 1024) : undefined,
517 },
518 };
519}
520
521const PLATFORM_DEPLOYS_TOPIC = "platform:deploys";
522
523events.post("/deploy/started", async (c) => {
524 const auth = verifyDeployBearer(c);
525 if (!auth.ok) {
526 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
527 }
528
529 let raw: unknown;
530 try {
531 raw = await c.req.json();
532 } catch {
533 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
534 }
535
536 const validated = validateStarted(raw);
537 if (!validated.ok) {
538 return c.json({ ok: false, error: validated.error }, 400);
539 }
540 const { sha, run_id, source } = validated.payload;
541
542 // INSERT-or-no-op on UNIQUE(run_id). If the row already exists we treat the
543 // call as a duplicate and don't republish — the original publish carried
544 // the canonical started-at timestamp.
545 let inserted: { id: string; startedAt: Date } | null = null;
546 try {
547 const rows = await db
548 .insert(platformDeploys)
549 .values({
550 runId: run_id,
551 sha,
552 source,
553 status: "in_progress",
554 })
555 .onConflictDoNothing({ target: platformDeploys.runId })
556 .returning({ id: platformDeploys.id, startedAt: platformDeploys.startedAt });
557 inserted = rows[0] ?? null;
558 } catch (err) {
559 console.error("[events/deploy/started] insert failed:", err);
560 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
561 }
562
563 if (!inserted) {
564 return c.json({ ok: true, duplicate: true });
565 }
566
567 publish(PLATFORM_DEPLOYS_TOPIC, {
568 event: "deploy.started",
569 data: {
570 id: inserted.id,
571 run_id,
572 sha,
573 source,
574 status: "in_progress",
575 started_at: inserted.startedAt.toISOString(),
576 },
577 });
578
579 return c.json({ ok: true, duplicate: false });
580});
581
582events.post("/deploy/finished", async (c) => {
583 const auth = verifyDeployBearer(c);
584 if (!auth.ok) {
585 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
586 }
587
588 let raw: unknown;
589 try {
590 raw = await c.req.json();
591 } catch {
592 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
593 }
594
595 const validated = validateFinished(raw);
596 if (!validated.ok) {
597 return c.json({ ok: false, error: validated.error }, 400);
598 }
599 const payload = validated.payload;
600
601 const finishedAt = new Date();
602
603 let row:
604 | {
605 id: string;
606 runId: string;
607 sha: string;
608 source: string;
609 status: string;
610 startedAt: Date;
611 finishedAt: Date | null;
612 durationMs: number | null;
613 error: string | null;
614 }
615 | null = null;
616
617 try {
618 const updated = await db
619 .update(platformDeploys)
620 .set({
621 status: payload.status,
622 finishedAt,
623 durationMs: payload.duration_ms ?? null,
624 error: payload.error ?? null,
625 })
626 .where(eq(platformDeploys.runId, payload.run_id))
627 .returning({
628 id: platformDeploys.id,
629 runId: platformDeploys.runId,
630 sha: platformDeploys.sha,
631 source: platformDeploys.source,
632 status: platformDeploys.status,
633 startedAt: platformDeploys.startedAt,
634 finishedAt: platformDeploys.finishedAt,
635 durationMs: platformDeploys.durationMs,
636 error: platformDeploys.error,
637 });
638 row = updated[0] ?? null;
639 } catch (err) {
640 console.error("[events/deploy/finished] update failed:", err);
641 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
642 }
643
644 // No matching started row — record a finished-only entry so the timeline
645 // still reflects the deploy. Source/sha fall back to defaults; this is the
646 // "started packet got dropped" recovery path.
647 if (!row) {
648 try {
649 const inserted = await db
650 .insert(platformDeploys)
651 .values({
652 runId: payload.run_id,
653 sha: payload.sha ?? "unknown",
654 source: "hetzner-deploy",
655 status: payload.status,
656 finishedAt,
657 durationMs: payload.duration_ms ?? null,
658 error: payload.error ?? null,
659 })
660 .returning({
661 id: platformDeploys.id,
662 runId: platformDeploys.runId,
663 sha: platformDeploys.sha,
664 source: platformDeploys.source,
665 status: platformDeploys.status,
666 startedAt: platformDeploys.startedAt,
667 finishedAt: platformDeploys.finishedAt,
668 durationMs: platformDeploys.durationMs,
669 error: platformDeploys.error,
670 });
671 row = inserted[0] ?? null;
672 } catch (err) {
673 console.error("[events/deploy/finished] backfill insert failed:", err);
674 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
675 }
676 }
677
678 if (row) {
679 publish(PLATFORM_DEPLOYS_TOPIC, {
680 event: "deploy.finished",
681 data: {
682 id: row.id,
683 run_id: row.runId,
684 sha: row.sha,
685 source: row.source,
686 status: row.status,
687 started_at: row.startedAt.toISOString(),
688 finished_at: row.finishedAt ? row.finishedAt.toISOString() : null,
689 duration_ms: row.durationMs,
690 error: row.error,
691 },
692 });
89a0761Claude693
694 // Level 3 — Self-diagnosing (2026-05-16 reliability sweep).
695 //
696 // On platform deploy failure, fire-and-forget call to Claude for a
697 // root-cause analysis. The RCA gets:
698 // - console.warn'd as a structured log entry (operators grep
699 // journalctl for [platform-incident])
700 // - inserted into audit_log so /admin/audit + future /admin/health
701 // can surface it without a fresh AI call
702 //
703 // Idempotent by run_id (same run_id can fire multiple finished
704 // events, but each will produce its own analysis — that's fine,
705 // operators get the freshest version).
706 if (row.status === "failed") {
707 void (async () => {
708 try {
709 const { analyzePlatformDeployFailure } = await import(
710 "../lib/ai-incident"
711 );
712 const result = await analyzePlatformDeployFailure({
713 runId: row.runId,
714 sha: row.sha,
715 errorMessage: row.error || payload.error || "(no error message)",
716 });
717 console.warn(
718 `[platform-incident] deploy ${row.runId} (${result.shortSha}) FAILED — RCA follows (aiAvailable=${result.aiAvailable}):\n${result.rcaMarkdown}`
719 );
720 try {
721 const { audit } = await import("../lib/notify");
722 await audit({
723 userId: null,
724 action: "platform.deploy.failed",
725 targetType: "platform_deploy",
726 targetId: row.id,
727 metadata: {
728 run_id: row.runId,
729 sha: row.sha,
730 short_sha: result.shortSha,
731 error: (row.error || "").slice(0, 500),
732 ai_rca: result.rcaMarkdown.slice(0, 8000),
733 ai_available: result.aiAvailable,
734 },
735 });
736 } catch (err) {
737 console.warn(
738 `[platform-incident] audit-log insert failed for run ${row.runId}:`,
739 err instanceof Error ? err.message : err
740 );
741 }
742 } catch (err) {
743 console.warn(
744 `[platform-incident] analysis pipeline failed for run ${row.runId}:`,
745 err instanceof Error ? err.message : err
746 );
747 }
748 })();
749 }
f764c07Claude750 }
751
752 return c.json({ ok: true });
753});
754
9dd96b9Test User755// ---------------------------------------------------------------------------
756// Block R2 — Live deploy log streaming via SSE.
757//
758// POST /api/events/deploy/step
759// Authorization: Bearer ${DEPLOY_EVENT_TOKEN}
760// Body: {
761// run_id: <string ≤128>,
762// sha: <hex 7-64>,
763// step_name: <string ≤64>,
764// status: "in_progress" | "succeeded" | "failed",
765// output?: <string, truncated to 8 KB>,
766// duration_ms?: <number ≥0>
767// }
768//
769// Behaviour:
770// 1. Look up `platform_deploys` by run_id. 404 if missing — the workflow's
771// `started` notification creates that row, so a step before /started is
772// operator error rather than a normal race.
773// 2. Insert a `platform_deploy_steps` row. Idempotent on
774// (deploy_id, step_name, status) — replaying the same transition is a
775// no-op (returns duplicate:true and does NOT republish SSE).
776// 3. Update the parent's `last_step` to the current step_name and bump
777// `step_count` on (status='succeeded' OR first 'in_progress' for this
778// step), so a page reload mid-deploy shows the last known position.
779// 4. Publish on TWO topics:
780// - `platform:deploys` (the N3 site-wide pill — coarse)
781// - `platform:deploys:<run_id>` (per-deploy fine-grained, drives the
782// admin-deploys modal)
783//
784// Auth: bearer `DEPLOY_EVENT_TOKEN`, same as /started + /finished. Refuse
785// by default when unset.
786// ---------------------------------------------------------------------------
787
788const VALID_STEP_STATUS: ReadonlySet<string> = new Set([
789 "in_progress",
790 "succeeded",
791 "failed",
792]);
793
794// Permissive enough for the workflow's `git-pull`, `bun-install`, `build`,
795// `db-migrate`, `restart-service`, `smoke-test`. Disallow anything that
796// could mess with SSE payload framing or DB display columns.
797const STEP_NAME_RE = /^[a-z0-9][a-z0-9_\-]{0,63}$/i;
798const STEP_OUTPUT_MAX = 8 * 1024;
799const STEP_OUTPUT_SSE_MAX = 2_000;
800
801interface DeployStepPayload {
802 run_id: string;
803 sha: string;
804 step_name: string;
805 status: "in_progress" | "succeeded" | "failed";
806 output?: string;
807 duration_ms?: number;
808}
809
810function validateStep(raw: unknown):
811 | { ok: true; payload: DeployStepPayload }
812 | { ok: false; error: string } {
813 if (!raw || typeof raw !== "object") {
814 return { ok: false, error: "Body must be a JSON object" };
815 }
816 const p = raw as Record<string, unknown>;
817 if (
818 typeof p.run_id !== "string" ||
819 p.run_id.length === 0 ||
820 p.run_id.length > 128
821 ) {
822 return {
823 ok: false,
824 error: "run_id must be a non-empty string (≤128 chars)",
825 };
826 }
827 if (typeof p.sha !== "string" || !SHORT_SHA_RE.test(p.sha)) {
828 return { ok: false, error: "sha must be a hex commit id (7-64 chars)" };
829 }
830 if (typeof p.step_name !== "string" || !STEP_NAME_RE.test(p.step_name)) {
831 return {
832 ok: false,
833 error:
834 "step_name must match /^[a-z0-9][a-z0-9_-]{0,63}$/i (e.g. git-pull, bun-install)",
835 };
836 }
837 if (typeof p.status !== "string" || !VALID_STEP_STATUS.has(p.status)) {
838 return {
839 ok: false,
840 error: "status must be 'in_progress', 'succeeded', or 'failed'",
841 };
842 }
843 if (p.duration_ms !== undefined) {
844 if (
845 typeof p.duration_ms !== "number" ||
846 !Number.isFinite(p.duration_ms) ||
847 p.duration_ms < 0
848 ) {
849 return {
850 ok: false,
851 error: "duration_ms must be a non-negative number when provided",
852 };
853 }
854 }
855 if (p.output !== undefined && typeof p.output !== "string") {
856 return { ok: false, error: "output must be a string when provided" };
857 }
858 return {
859 ok: true,
860 payload: {
861 run_id: p.run_id,
862 sha: p.sha,
863 step_name: p.step_name,
864 status: p.status as "in_progress" | "succeeded" | "failed",
865 output:
866 typeof p.output === "string"
867 ? p.output.slice(0, STEP_OUTPUT_MAX)
868 : undefined,
869 duration_ms:
870 typeof p.duration_ms === "number" ? p.duration_ms : undefined,
871 },
872 };
873}
874
875/** SSE topic for a single deploy (drives the admin-deploys modal). */
876function perDeployTopic(runId: string): string {
877 return `${PLATFORM_DEPLOYS_TOPIC}:${runId}`;
878}
879
880events.post("/deploy/step", async (c) => {
881 const auth = verifyDeployBearer(c);
882 if (!auth.ok) {
883 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
884 }
885
886 let raw: unknown;
887 try {
888 raw = await c.req.json();
889 } catch {
890 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
891 }
892
893 const validated = validateStep(raw);
894 if (!validated.ok) {
895 return c.json({ ok: false, error: validated.error }, 400);
896 }
897 const payload = validated.payload;
898
899 // --- 1. Resolve the parent deploy row -----------------------------------
900 let deploy: { id: string; stepCount: number } | null = null;
901 try {
902 const [row] = await db
903 .select({
904 id: platformDeploys.id,
905 stepCount: platformDeploys.stepCount,
906 })
907 .from(platformDeploys)
908 .where(eq(platformDeploys.runId, payload.run_id))
909 .limit(1);
910 deploy = row ?? null;
911 } catch (err) {
912 console.error("[events/deploy/step] parent lookup failed:", err);
913 return c.json({ ok: false, error: "Failed to read deploy state" }, 500);
914 }
915
916 if (!deploy) {
917 return c.json(
918 {
919 ok: false,
920 error:
921 "No platform_deploys row for run_id — POST /api/events/deploy/started first",
922 },
923 404
924 );
925 }
926
927 // --- 2. Insert the step row (idempotent on transition) ------------------
928 let duplicate = false;
929 let stepFinishedAt: Date | null = null;
930 try {
931 const insertValues: Record<string, unknown> = {
932 deployId: deploy.id,
933 stepName: payload.step_name,
934 status: payload.status,
935 output: payload.output ?? null,
936 durationMs: payload.duration_ms ?? null,
937 };
938 if (payload.status !== "in_progress") {
939 stepFinishedAt = new Date();
940 insertValues.finishedAt = stepFinishedAt;
941 }
942 const inserted = await db
943 .insert(platformDeploySteps)
944 .values(insertValues as any)
945 .onConflictDoNothing({
946 target: [
947 platformDeploySteps.deployId,
948 platformDeploySteps.stepName,
949 platformDeploySteps.status,
950 ],
951 })
952 .returning({ id: platformDeploySteps.id });
953 duplicate = inserted.length === 0;
954 } catch (err) {
955 const msg = err instanceof Error ? err.message : String(err);
956 if (msg.includes("unique") || msg.includes("duplicate")) {
957 duplicate = true;
958 } else {
959 console.error("[events/deploy/step] insert failed:", err);
960 return c.json({ ok: false, error: "Failed to persist step" }, 500);
961 }
962 }
963
964 if (duplicate) {
965 return c.json({ ok: true, duplicate: true });
966 }
967
968 // --- 3. Update the parent's last_step + step_count ----------------------
969 // Only bump the counter on transitions that represent forward progress:
970 // - 'succeeded' (the step finished cleanly)
971 // - first 'in_progress' transition for THIS step (we haven't seen its
972 // name yet) — handled implicitly because the idempotency dedupe above
973 // already filters out a second in_progress for the same step.
974 //
975 // 'failed' updates last_step but does NOT bump the counter — the deploy
976 // is wedged, no more progress to count.
977 try {
978 if (payload.status === "failed") {
979 await db
980 .update(platformDeploys)
981 .set({ lastStep: payload.step_name })
982 .where(eq(platformDeploys.id, deploy.id));
983 } else {
984 await db
985 .update(platformDeploys)
986 .set({
987 lastStep: payload.step_name,
988 stepCount: sql`${platformDeploys.stepCount} + 1`,
989 })
990 .where(eq(platformDeploys.id, deploy.id));
991 }
992 } catch (err) {
993 // Persistence failure on the rollup column must not stop the SSE push
994 // — the modal will still show the live state, the page refresh just
995 // won't carry the last_step. Log and continue.
996 console.error("[events/deploy/step] parent rollup update failed:", err);
997 }
998
999 // --- 4. Publish to both SSE topics --------------------------------------
1000 const ssePayload = {
1001 event: "step",
1002 data: JSON.stringify({
1003 run_id: payload.run_id,
1004 step_name: payload.step_name,
1005 status: payload.status,
1006 duration_ms: payload.duration_ms ?? null,
1007 output: payload.output
1008 ? payload.output.slice(0, STEP_OUTPUT_SSE_MAX)
1009 : null,
1010 finished_at: stepFinishedAt ? stepFinishedAt.toISOString() : null,
1011 }),
1012 };
1013 publish(PLATFORM_DEPLOYS_TOPIC, ssePayload);
1014 publish(perDeployTopic(payload.run_id), ssePayload);
1015
1016 return c.json({ ok: true, duplicate: false });
1017});
1018
9e1e93aClaude1019// Test-only access for unit tests that want to exercise helpers directly.
1020export const __test = {
1021 constantTimeEq,
1022 validatePayload,
1023 resolveRepo,
1024 findTargetDeployment,
f764c07Claude1025 validateStarted,
1026 validateFinished,
9dd96b9Test User1027 validateStep,
f764c07Claude1028 verifyDeployBearer,
9dd96b9Test User1029 perDeployTopic,
f764c07Claude1030 PLATFORM_DEPLOYS_TOPIC,
9e1e93aClaude1031};
1032
1033export default events;