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