Spaces:
Running
Running
| "use client"; | |
| import { useAuth } from "@clerk/nextjs"; | |
| import { useCallback, useState } from "react"; | |
| function parseNdjsonChunk( | |
| buffer: string, | |
| onObject: (o: unknown) => void, | |
| ): string { | |
| const lines = buffer.split("\n"); | |
| const rest = lines.pop() ?? ""; | |
| for (const row of lines) { | |
| const t = row.trim(); | |
| if (!t) continue; | |
| try { | |
| onObject(JSON.parse(t) as unknown); | |
| } catch { | |
| onObject({ error: `Bad JSON line: ${t.slice(0, 120)}` }); | |
| } | |
| } | |
| return rest; | |
| } | |
| function isProgress(o: unknown): o is { kind: "progress"; message: string } { | |
| return ( | |
| typeof o === "object" && | |
| o !== null && | |
| (o as { kind?: string }).kind === "progress" && | |
| typeof (o as { message?: string }).message === "string" | |
| ); | |
| } | |
| export function DigestDebugger() { | |
| const { userId: myId } = useAuth(); | |
| const [userId, setUserId] = useState(""); | |
| const [ignoreRedisHistory, setIgnoreRedisHistory] = useState(false); | |
| const [dryRunEmail, setDryRunEmail] = useState(false); | |
| const [force, setForce] = useState(false); | |
| const [skipCredits, setSkipCredits] = useState(true); | |
| const [busy, setBusy] = useState(false); | |
| const [logLines, setLogLines] = useState<string[]>([]); | |
| const [lastResult, setLastResult] = useState<unknown>(null); | |
| const append = useCallback((s: string) => { | |
| setLogLines((prev) => [...prev, s]); | |
| }, []); | |
| const runDigest = useCallback(async () => { | |
| const uid = userId.trim(); | |
| if (!uid) { | |
| append("Enter a Clerk userId."); | |
| return; | |
| } | |
| setBusy(true); | |
| setLogLines([]); | |
| setLastResult(null); | |
| append(`POST /api/admin/trigger-digest (userId=${uid.slice(0, 12)}…)`); | |
| try { | |
| const res = await fetch("/api/admin/trigger-digest", { | |
| method: "POST", | |
| credentials: "same-origin", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| userId: uid, | |
| ignoreRedisHistory, | |
| dryRunEmail, | |
| force, | |
| skipCredits, | |
| }), | |
| }); | |
| if (!res.ok || !res.body) { | |
| const text = await res.text(); | |
| append(`HTTP ${res.status}: ${text.slice(0, 500)}`); | |
| return; | |
| } | |
| const reader = res.body.getReader(); | |
| const dec = new TextDecoder(); | |
| let buf = ""; | |
| for (;;) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buf += dec.decode(value, { stream: true }); | |
| buf = parseNdjsonChunk(buf, (obj) => { | |
| if ( | |
| typeof obj === "object" && | |
| obj !== null && | |
| "error" in obj && | |
| typeof (obj as { error?: string }).error === "string" | |
| ) { | |
| append(`[error] ${(obj as { error: string }).error}`); | |
| return; | |
| } | |
| if (isProgress(obj)) { | |
| append(obj.message); | |
| } else if ( | |
| typeof obj === "object" && | |
| obj !== null && | |
| (obj as { kind?: string }).kind === "classified" && | |
| typeof (obj as { message?: string }).message === "string" | |
| ) { | |
| append(`→ ${(obj as { message: string }).message}`); | |
| } else if ( | |
| typeof obj === "object" && | |
| obj !== null && | |
| (obj as { kind?: string }).kind === "error" && | |
| typeof (obj as { message?: string }).message === "string" | |
| ) { | |
| append(`[throw] ${(obj as { message: string }).message}`); | |
| } else if ( | |
| typeof obj === "object" && | |
| obj !== null && | |
| (obj as { kind?: string }).kind === "done" | |
| ) { | |
| const res = (obj as { result?: unknown }).result; | |
| setLastResult(res ?? null); | |
| append(`[done] ${JSON.stringify(res, null, 2)}`); | |
| } | |
| }); | |
| } | |
| if (buf.trim()) { | |
| try { | |
| const o = JSON.parse(buf) as unknown; | |
| if (isProgress(o)) append(o.message); | |
| else if ( | |
| typeof o === "object" && | |
| o !== null && | |
| (o as { kind?: string }).kind === "classified" && | |
| typeof (o as { message?: string }).message === "string" | |
| ) | |
| append(`→ ${(o as { message: string }).message}`); | |
| else if ( | |
| typeof o === "object" && | |
| o !== null && | |
| (o as { kind?: string }).kind === "error" && | |
| typeof (o as { message?: string }).message === "string" | |
| ) | |
| append(`[throw] ${(o as { message: string }).message}`); | |
| else if ( | |
| typeof o === "object" && | |
| o !== null && | |
| (o as { kind?: string }).kind === "done" | |
| ) { | |
| const res = (o as { result?: unknown }).result; | |
| setLastResult(res ?? null); | |
| append(`[done] ${JSON.stringify(res, null, 2)}`); | |
| } | |
| } catch { | |
| append(`[incomplete] ${buf.slice(0, 200)}`); | |
| } | |
| } | |
| } catch (e) { | |
| append(e instanceof Error ? e.message : String(e)); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }, [ | |
| append, | |
| dryRunEmail, | |
| force, | |
| ignoreRedisHistory, | |
| skipCredits, | |
| userId, | |
| ]); | |
| return ( | |
| <section | |
| style={{ | |
| padding: 14, | |
| borderRadius: 10, | |
| border: "1px solid var(--border-subtle)", | |
| background: "var(--bg)", | |
| }} | |
| > | |
| <h2 | |
| style={{ | |
| fontSize: 11, | |
| fontWeight: 600, | |
| textTransform: "uppercase", | |
| letterSpacing: 0.5, | |
| color: "var(--muted)", | |
| marginBottom: 8, | |
| }} | |
| > | |
| Digest debugger | |
| </h2> | |
| <p style={{ fontSize: 12, color: "var(--muted)", marginBottom: 10 }}> | |
| Manually runs the same digest pipeline as QStash →{" "} | |
| <code style={{ fontSize: 11 }}>/api/internal/digest/process</code>. | |
| Set{" "} | |
| <code style={{ fontSize: 11 }}>DIGEST_TRACK_EMAILED_URLS=1</code> to | |
| dedupe emailed URLs in Redis; use “Ignore Redis history” to bypass that | |
| and inactive-engagement pause for repeat tests. | |
| </p> | |
| <div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 10 }}> | |
| <label style={{ fontSize: 13, display: "flex", flexDirection: "column", gap: 4 }}> | |
| Clerk user ID | |
| <input | |
| value={userId} | |
| onChange={(e) => setUserId(e.target.value)} | |
| placeholder={myId ?? "user_…"} | |
| spellCheck={false} | |
| style={{ fontFamily: "ui-monospace, monospace", fontSize: 12, padding: "6px 8px" }} | |
| /> | |
| </label> | |
| <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}> | |
| <input | |
| type="checkbox" | |
| checked={ignoreRedisHistory} | |
| onChange={(e) => setIgnoreRedisHistory(e.target.checked)} | |
| /> | |
| Ignore Redis history (inactive pause + emailed-URL dedupe when enabled) | |
| </label> | |
| <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}> | |
| <input | |
| type="checkbox" | |
| checked={dryRunEmail} | |
| onChange={(e) => setDryRunEmail(e.target.checked)} | |
| /> | |
| Dry-run agent report (no Resend; refunds digest credit if charged) | |
| </label> | |
| <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}> | |
| <input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} /> | |
| Force run (ignore digest disabled / subscription gate) | |
| </label> | |
| <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}> | |
| <input | |
| type="checkbox" | |
| checked={skipCredits} | |
| onChange={(e) => setSkipCredits(e.target.checked)} | |
| /> | |
| Skip credit charge (recommended for tests) | |
| </label> | |
| </div> | |
| <button | |
| type="button" | |
| className="primary-btn" | |
| disabled={busy} | |
| onClick={() => void runDigest()} | |
| > | |
| {busy ? "Running…" : "Force run agent report"} | |
| </button> | |
| <div style={{ marginTop: 12 }}> | |
| <div | |
| style={{ | |
| fontSize: 11, | |
| fontWeight: 600, | |
| color: "var(--muted)", | |
| marginBottom: 6, | |
| }} | |
| > | |
| Live log | |
| </div> | |
| <pre | |
| style={{ | |
| margin: 0, | |
| padding: 10, | |
| maxHeight: 280, | |
| overflow: "auto", | |
| fontSize: 11, | |
| lineHeight: 1.45, | |
| background: "#111", | |
| color: "#b8c0cc", | |
| borderRadius: 8, | |
| whiteSpace: "pre-wrap", | |
| wordBreak: "break-word", | |
| }} | |
| > | |
| {logLines.length === 0 ? "(no output yet)" : logLines.join("\n")} | |
| </pre> | |
| </div> | |
| {lastResult !== null ? ( | |
| <p style={{ fontSize: 11, color: "var(--muted)", marginTop: 8 }}> | |
| Last result object is included in the log as{" "} | |
| <code style={{ fontSize: 10 }}>[done]</code>. | |
| </p> | |
| ) : null} | |
| </section> | |
| ); | |
| } | |