Job-Scorer / components /lab /DigestDebugger.tsx
zimejin's picture
Refactor email to Agent Inbox: Resend-only reports with unified agent voice.
66b5e08
Raw
History Blame Contribute Delete
9.06 kB
"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>
);
}