Skip to content

Commit b914000

Browse files
committed
perf(upload): instant save + live agents after every file
- Compress phone photos in the browser before PUT (smaller, faster) - Mark paper saved on confirm; OCR/notice AI continues in background - Dual agent wake: in-process drain + HTTP /internal/jobs/process - Cron job drain every 5 minutes so the queue cannot sleep long
1 parent 309250d commit b914000

8 files changed

Lines changed: 212 additions & 56 deletions

File tree

.github/workflows/cron.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ permissions:
1414

1515
on:
1616
schedule:
17-
- cron: "*/10 * * * *" # every 10 minutes (GitHub Actions minimum is 5)
17+
- cron: "*/5 * * * *" # every 5 minutes (GitHub Actions minimum is 5)
1818
workflow_dispatch: {}
1919

2020
concurrency:

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable product changes are documented here. The project follows a pre-1.0,
44

55
## Unreleased
66

7+
### Performance
8+
9+
- Client compresses phone photos before upload (faster PUT + confirm).
10+
- Upload UI finishes on confirm; OCR/notice AI runs in background (no 60s spinner).
11+
- Dual job kick: in-process + HTTP wake of `/internal/jobs/process` so agents never sleep on Hobby.
12+
- GitHub Actions job drain every 5 minutes (was 10).
13+
14+
715
### Performance
816

917
- Fix /healthz soft-404 by rewriting in proxy.ts before next-intl locale routing.

app/api/v1/cases/[id]/evidence/[eid]/confirm/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, after } from "next/server";
22
import { assertCaseAccess, requireRequestAuth } from "@/lib/api/case-access";
33
import { enqueueAgentJob } from "@/lib/jobs/enqueue";
4-
import { kickPendingJobs } from "@/lib/jobs/kick";
4+
import { kickPendingJobs, httpKickJobWorker } from "@/lib/jobs/kick";
55
import { isValidSha256 } from "@/lib/evidence/sha256";
66
import {
77
EVIDENCE_BUCKET,
@@ -270,6 +270,8 @@ export async function POST(request: NextRequest, context: RouteContext) {
270270
} catch {
271271
// Job stays pending for the cron sweeper.
272272
}
273+
// Separate worker so AI is never stuck waiting for the next GitHub cron.
274+
void httpKickJobWorker(8);
273275
};
274276

275277
try {

components/escalations/MarkSentForm.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Loader2 } from 'lucide-react';
66
import { cn } from '@/lib/ui/cn';
77
import { computeSha256HexInBrowser } from '@/lib/evidence/sha256';
88
import { validateUploadFile } from '@/lib/evidence/validate-file';
9+
import { prepareFileForUpload } from '@/lib/evidence/compress-for-upload';
910
import { track } from '@/lib/analytics/events';
1011

1112
type MarkSentFormProps = {
@@ -62,14 +63,22 @@ export function MarkSentForm({
6263

6364
setPhase('uploading');
6465
try {
66+
let uploadFile = proofFile;
67+
try {
68+
uploadFile = await prepareFileForUpload(proofFile);
69+
} catch {
70+
uploadFile = proofFile;
71+
}
72+
const uploadMime = uploadFile.type || proofFile.type || 'application/octet-stream';
73+
6574
const urlRes = await fetch(`/api/v1/cases/${caseId}/evidence/upload-url`, {
6675
method: 'POST',
6776
headers: jsonHeaders,
6877
body: JSON.stringify({
6978
evidence_type: 'letter_sent_proof',
70-
filename: proofFile.name,
71-
mime_type: proofFile.type,
72-
file_size_bytes: proofFile.size,
79+
filename: uploadFile.name,
80+
mime_type: uploadMime,
81+
file_size_bytes: uploadFile.size,
7382
}),
7483
});
7584
const urlJson = await urlRes.json();
@@ -79,14 +88,14 @@ export function MarkSentForm({
7988

8089
const putRes = await fetch(urlJson.upload_url, {
8190
method: 'PUT',
82-
headers: { 'Content-Type': proofFile.type },
83-
body: proofFile,
91+
headers: { 'Content-Type': uploadMime },
92+
body: uploadFile,
8493
});
8594
if (!putRes.ok) {
8695
throw new Error(t('uploadFailed'));
8796
}
8897

89-
const sha256 = await computeSha256HexInBrowser(proofFile);
98+
const sha256 = await computeSha256HexInBrowser(uploadFile);
9099
const confirmRes = await fetch(
91100
`/api/v1/cases/${caseId}/evidence/${urlJson.evidence_id}/confirm`,
92101
{

components/evidence/PapersChecklist.tsx

Lines changed: 47 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Loader2 } from "lucide-react";
77
import { computeSha256HexInBrowser } from "@/lib/evidence/sha256";
88
import { resolveEvidenceMime } from "@/lib/evidence/accepted-mime";
99
import { validateUploadFile } from "@/lib/evidence/validate-file";
10+
import { prepareFileForUpload } from "@/lib/evidence/compress-for-upload";
1011
import { classifyDoc, type DocClass } from "@/lib/evidence/readability";
1112
import type { PaperDocDef } from "@/lib/intake/paper-display";
1213

@@ -101,10 +102,9 @@ const DEFAULT_EXTRA_DOCS: PaperDocDef[] = [
101102
},
102103
];
103104

104-
// Snappier feedback: the verify API itself is ~0.5s, so poll fast early. Total
105-
// window stays ~60s (40 × 1.5s) before the calm background-check fallback.
106-
const POLL_INTERVAL_MS = 1500;
107-
const POLL_MAX_ATTEMPTS = 40;
105+
// Fast early polls while AI runs in after(); total window ~45s then background.
106+
const POLL_INTERVAL_MS = 800;
107+
const POLL_MAX_ATTEMPTS = 55;
108108

109109
function docClassOf(v: PaperVerification): DocClass | null {
110110
if (!v) return null;
@@ -236,12 +236,19 @@ export function PapersChecklist({
236236
patchRow(type, { status: "error", error: fileProblem });
237237
return;
238238
}
239-
const mimeType = resolveEvidenceMime(file);
239+
// Shrink phone photos before hash/PUT so upload + confirm are not multi-MB.
240+
let uploadFile = file;
241+
try {
242+
uploadFile = await prepareFileForUpload(file);
243+
} catch {
244+
uploadFile = file;
245+
}
246+
const mimeType = resolveEvidenceMime(uploadFile);
240247

241248
// Compute the hash up front so the same file can't be added to two slots.
242249
let sha256: string;
243250
try {
244-
sha256 = await computeSha256HexInBrowser(file);
251+
sha256 = await computeSha256HexInBrowser(uploadFile);
245252
} catch {
246253
patchRow(type, { status: "error", error: t("fileReadError") });
247254
return;
@@ -272,9 +279,9 @@ export function PapersChecklist({
272279
headers: authHeaders(),
273280
body: JSON.stringify({
274281
evidence_type: type,
275-
filename: file.name,
282+
filename: uploadFile.name,
276283
mime_type: mimeType,
277-
file_size_bytes: file.size,
284+
file_size_bytes: uploadFile.size,
278285
}),
279286
},
280287
);
@@ -285,7 +292,7 @@ export function PapersChecklist({
285292
const putRes = await fetch(urlJson.upload_url, {
286293
method: "PUT",
287294
headers: { "Content-Type": mimeType },
288-
body: file,
295+
body: uploadFile,
289296
});
290297
if (!putRes.ok) throw new Error(t("storageError"));
291298

@@ -309,36 +316,38 @@ export function PapersChecklist({
309316

310317
router.refresh();
311318

312-
if (!isCore) {
313-
// Extras show a simple "Added" — the seal + background check still run.
314-
patchRow(type, { status: "done", pending: true });
315-
return;
316-
}
317-
318-
patchRow(type, { status: "checking" });
319-
const verification = await pollVerification(urlJson.evidence_id);
320-
if (verification === "timeout") {
321-
patchRow(type, { status: "done", pending: true, verification: null });
322-
} else {
323-
patchRow(type, { status: "done", verification, pending: false });
324-
}
325-
326-
if (type === "freeze_sms") {
327-
// Deliver the "AI explains your notice" promise — advisory, never blocks.
328-
try {
329-
await fetch(`/api/v1/cases/${caseId}/notice-analysis`, {
330-
method: "POST",
331-
headers: authHeaders(),
332-
body: JSON.stringify({
333-
input_kind: "image",
334-
evidence_id: urlJson.evidence_id,
335-
}),
336-
});
337-
} catch {
338-
// best-effort — the papers page still works without the explanation
319+
// Upload is DONE as soon as confirm returns. AI/OCR continues on the
320+
// server (after() + job queue) — do not block the user on model latency.
321+
patchRow(type, { status: "done", pending: true, verification: null });
322+
323+
const evidenceId = urlJson.evidence_id as string;
324+
void (async () => {
325+
if (isCore) {
326+
const verification = await pollVerification(evidenceId);
327+
if (verification !== "timeout") {
328+
patchRow(type, {
329+
status: "done",
330+
verification,
331+
pending: false,
332+
});
333+
router.refresh();
334+
}
339335
}
340-
}
341-
router.refresh();
336+
if (type === "freeze_sms") {
337+
try {
338+
await fetch(`/api/v1/cases/${caseId}/notice-analysis`, {
339+
method: "POST",
340+
headers: authHeaders(),
341+
body: JSON.stringify({
342+
input_kind: "image",
343+
evidence_id: evidenceId,
344+
}),
345+
});
346+
} catch {
347+
// best-effort advisory — papers page still works without it
348+
}
349+
}
350+
})();
342351
} catch (error) {
343352
patchRow(type, {
344353
status: "error",
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Browser-side resize before PUT so multi‑MB phone photos (often 4–12 MB)
3+
* become ~150–400 KB JPEGs. That speeds storage upload, server confirm
4+
* re-download/hash, and vision OCR payload size.
5+
*
6+
* PDFs and already-small images pass through unchanged. HEIC that the browser
7+
* cannot decode falls back to the original file (server sharp still handles it).
8+
*/
9+
10+
const MAX_EDGE = 1600;
11+
const JPEG_QUALITY = 0.82;
12+
/** Skip work when the file is already light enough for a fast path. */
13+
const SKIP_UNDER_BYTES = 450_000;
14+
15+
function isPdf(file: File): boolean {
16+
return (
17+
file.type === "application/pdf" ||
18+
file.name.toLowerCase().endsWith(".pdf")
19+
);
20+
}
21+
22+
function isLikelyImage(file: File): boolean {
23+
if (file.type.startsWith("image/")) return true;
24+
return /\.(jpe?g|png|webp|heic|heif|gif)$/i.test(file.name);
25+
}
26+
27+
/**
28+
* @returns A File ready to hash + upload (possibly compressed JPEG).
29+
*/
30+
export async function prepareFileForUpload(file: File): Promise<File> {
31+
if (isPdf(file)) return file;
32+
if (!isLikelyImage(file)) return file;
33+
if (file.size > 0 && file.size <= SKIP_UNDER_BYTES) return file;
34+
35+
// createImageBitmap handles JPEG/PNG/WebP in modern browsers; HEIC varies.
36+
let bitmap: ImageBitmap;
37+
try {
38+
bitmap = await createImageBitmap(file);
39+
} catch {
40+
return file;
41+
}
42+
43+
try {
44+
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
45+
const width = Math.max(1, Math.round(bitmap.width * scale));
46+
const height = Math.max(1, Math.round(bitmap.height * scale));
47+
48+
const canvas = document.createElement("canvas");
49+
canvas.width = width;
50+
canvas.height = height;
51+
const ctx = canvas.getContext("2d");
52+
if (!ctx) return file;
53+
54+
ctx.drawImage(bitmap, 0, 0, width, height);
55+
bitmap.close();
56+
57+
const blob = await new Promise<Blob | null>((resolve) => {
58+
canvas.toBlob((b) => resolve(b), "image/jpeg", JPEG_QUALITY);
59+
});
60+
if (!blob || blob.size === 0) return file;
61+
62+
// Prefer compressed only when it actually shrinks the upload.
63+
if (blob.size >= file.size * 0.95) return file;
64+
65+
const base = file.name.replace(/\.[^.]+$/, "") || "evidence";
66+
return new File([blob], `${base}.jpg`, {
67+
type: "image/jpeg",
68+
lastModified: Date.now(),
69+
});
70+
} catch {
71+
try {
72+
bitmap.close();
73+
} catch {
74+
// ignore
75+
}
76+
return file;
77+
}
78+
}

lib/jobs/kick.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,53 @@ import "server-only";
22

33
/**
44
* Fire-and-forget drain of pending agent_jobs so user-facing work
5-
* (verifier / drafter) does not wait solely for the 10-minute Hobby cron.
5+
* (verifier / drafter) does not wait solely for the Hobby cron.
66
*
7-
* Safe to call from request handlers, after(), and agent runners:
8-
* - single-flight (overlapping kicks collapse)
9-
* - failures are swallowed; durable cron still drains
7+
* Two paths:
8+
* 1. In-process `processAgentJobs` (same serverless isolate, single-flight)
9+
* 2. HTTP POST to `/api/v1/internal/jobs/process` (wakes a fresh worker if
10+
* the in-process path is busy/unavailable — agents stay alive)
1011
*/
1112

1213
let inFlight: Promise<{ processed: number; succeeded: number; failed: number } | null> | null =
1314
null;
1415

16+
function appBaseUrl(): string | null {
17+
const raw =
18+
process.env.NEXT_PUBLIC_APP_URL?.trim() ||
19+
process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() ||
20+
process.env.VERCEL_URL?.trim() ||
21+
"";
22+
if (!raw) return null;
23+
if (raw.startsWith("http://") || raw.startsWith("https://")) {
24+
return raw.replace(/\/$/, "");
25+
}
26+
return `https://${raw.replace(/\/$/, "")}`;
27+
}
28+
29+
/** Best-effort wake of the job worker via authenticated internal route. */
30+
export async function httpKickJobWorker(limit = 8): Promise<boolean> {
31+
const base = appBaseUrl();
32+
const secret = process.env.CRON_SECRET?.trim();
33+
if (!base || !secret) return false;
34+
35+
try {
36+
const res = await fetch(`${base}/api/v1/internal/jobs/process`, {
37+
method: "POST",
38+
headers: {
39+
Authorization: `Bearer ${secret}`,
40+
"Content-Type": "application/json",
41+
},
42+
body: JSON.stringify({ limit }),
43+
// Do not hang the parent forever if the worker is slow.
44+
signal: AbortSignal.timeout(55_000),
45+
});
46+
return res.ok;
47+
} catch {
48+
return false;
49+
}
50+
}
51+
1552
export async function kickPendingJobs(limit = 8): Promise<{
1653
processed: number;
1754
succeeded: number;
@@ -22,8 +59,16 @@ export async function kickPendingJobs(limit = 8): Promise<{
2259
inFlight = (async () => {
2360
try {
2461
const { processAgentJobs } = await import("@/lib/jobs/process");
25-
return await processAgentJobs({ limit });
62+
const result = await processAgentJobs({ limit });
63+
// If nothing was processed (empty queue race) still ok; if we had work
64+
// and more may remain, the HTTP kick below can drain residual.
65+
if (result.processed >= limit) {
66+
void httpKickJobWorker(limit);
67+
}
68+
return result;
2669
} catch {
70+
// Isolate may lack DB; try HTTP worker so agents are not "dead".
71+
await httpKickJobWorker(limit);
2772
return null;
2873
} finally {
2974
inFlight = null;
@@ -35,11 +80,15 @@ export async function kickPendingJobs(limit = 8): Promise<{
3580

3681
/**
3782
* Schedule a queue kick after the HTTP response when possible (Next `after`),
38-
* otherwise run immediately (cron / scripts / tests without request scope).
83+
* otherwise run immediately. Always dual-paths: in-process + HTTP wake.
3984
*/
4085
export function scheduleJobKick(limit = 8): void {
4186
const run = () => {
42-
void kickPendingJobs(limit);
87+
void (async () => {
88+
await kickPendingJobs(limit);
89+
// Second wake in a separate worker so backlog never sits until cron.
90+
void httpKickJobWorker(limit);
91+
})();
4392
};
4493

4594
void import("next/server")

0 commit comments

Comments
 (0)