diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6d9153d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# Error/Logging/Sanitization Standards + +This project uses strict operability and privacy defaults for server-side code. + +## API Response Contract + +For all `/api/*` routes, return: + +- Success: `{ ok: true, data, meta?: { requestId } }` +- Error: `{ ok: false, error: { code, message, details? }, meta: { requestId } }` + +Use consistent status/code mapping: + +- `400 INVALID_REQUEST` +- `401 UNAUTHORIZED` +- `403 FORBIDDEN` +- `404 NOT_FOUND` +- `408 REQUEST_TIMEOUT` +- `409 CONFLICT` +- `422 UNPROCESSABLE_ENTITY` +- `500 INTERNAL_ERROR` +- `502 UPSTREAM_ERROR` +- `503 SERVICE_UNAVAILABLE` + +## Correlation IDs + +- Honor inbound `x-request-id` when present; otherwise generate one. +- Always return `x-request-id` header. +- Include request ID in API responses (`meta.requestId`) and logs. +- Propagate context into async flows (especially pipeline run and per-job work) so logs include `pipelineRunId` / `jobId` when available. + +## Logging Rules + +- Use the shared logger wrapper (`infra/logger.ts`) in core server paths. +- Do not add direct `console.log`, `console.warn`, or `console.error` in core paths. +- Log structured objects, not free-form dumps. +- Include useful context fields (e.g. `requestId`, `pipelineRunId`, `jobId`, `route`, `status`). + +## Redaction and Sanitization + +- Always sanitize objects before logging or returning in error `details`. +- Redact sensitive keys by default (`authorization`, `cookie`, `password`, `secret`, `token`, `apiKey`, etc.). +- Truncate large payloads and long strings. +- Do not throw/log raw upstream response bodies, full webhook bodies, or large `JSON.stringify(...)` blobs. + +## Webhook and LLM Payload Defaults + +- Webhooks: send minimal whitelisted payloads by default. +- LLM prompts: send only required profile/job fields; avoid unnecessary PII. +- Document external payload behavior when adding new integrations. + +## PR Checklist (Routes/Services) + +- API responses follow `{ ok, data/error, meta.requestId }`. +- Status/code mapping is correct and consistent. +- Request/correlation IDs appear in logs and async workflows. +- No raw sensitive payload logging or raw upstream body throws. +- New/changed webhook or LLM payloads are sanitized and documented. diff --git a/documentation/orchestrator.md b/documentation/orchestrator.md index 4c2fa08..93bcec0 100644 --- a/documentation/orchestrator.md +++ b/documentation/orchestrator.md @@ -92,3 +92,11 @@ POST /api/jobs/:id/generate-pdf - `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`. - The PDF is served at `/pdfs/resume_.pdf` and cache-busted with the job?s `updatedAt` timestamp. - If a job is `skipped` or `applied` and you want to re-open it, you can PATCH its `status` back to `discovered`. + +## External payload and sanitization defaults + +- **LLM providers** receive only prompt inputs required for scoring/tailoring/project selection/manual extraction tasks. +- By default, prompt construction uses minimized profile/job fields and avoids sending unnecessary sensitive data. +- **Webhook payloads** are sanitized and whitelisted by default; large/sensitive blobs are not sent. +- Server logs and error details are redacted/truncated by default (secrets, tokens, cookies, passwords, API keys, and oversized payload fields). +- Correlation data is included in logs (`requestId`, and when available `pipelineRunId` / `jobId`) to improve traceability without exposing raw payloads. diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 5665bbb..aeb799c 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -35,6 +35,45 @@ import { trackEvent } from "@/lib/analytics"; const API_BASE = "/api"; +class ApiClientError extends Error { + requestId?: string; + + constructor(message: string, requestId?: string) { + super(requestId ? `${message} (requestId: ${requestId})` : message); + this.name = "ApiClientError"; + this.requestId = requestId; + } +} + +type LegacyApiResponse = + | { + success: true; + data?: T; + message?: string; + } + | { + success: false; + error?: string; + message?: string; + details?: unknown; + }; + +function normalizeApiResponse( + payload: unknown, +): ApiResponse | LegacyApiResponse { + if (!payload || typeof payload !== "object") { + throw new ApiClientError("API request failed: malformed JSON response"); + } + const response = payload as Record; + if (typeof response.ok === "boolean") { + return payload as ApiResponse; + } + if (typeof response.success === "boolean") { + return payload as LegacyApiResponse; + } + throw new ApiClientError("API request failed: unexpected response shape"); +} + async function fetchApi( endpoint: string, options?: RequestInit, @@ -49,22 +88,38 @@ async function fetchApi( const text = await response.text(); - let data: ApiResponse; + let payload: unknown; try { - data = JSON.parse(text); + payload = JSON.parse(text); } catch { // If the response is not JSON, it's likely an HTML error page console.error("API returned non-JSON response:", text.substring(0, 500)); - throw new Error( + throw new ApiClientError( `Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`, ); } + const parsed = normalizeApiResponse(payload); - if (!data.success) { - throw new Error(data.error || "API request failed"); + if ("ok" in parsed) { + if (!parsed.ok) { + throw new ApiClientError( + parsed.error.message || "API request failed", + parsed.meta?.requestId, + ); + } + return parsed.data as T; } - return data.data as T; + if (!parsed.success) { + throw new ApiClientError( + parsed.error || parsed.message || "API request failed", + ); + } + + const data = parsed.data; + if (data !== undefined) return data as T; + if (parsed.message !== undefined) return { message: parsed.message } as T; + return null as T; } // Jobs API diff --git a/orchestrator/src/server/api/routes/backup.test.ts b/orchestrator/src/server/api/routes/backup.test.ts index 4d47102..cd963fb 100644 --- a/orchestrator/src/server/api/routes/backup.test.ts +++ b/orchestrator/src/server/api/routes/backup.test.ts @@ -23,7 +23,7 @@ describe.sequential("Backup API routes", () => { const body = await res.json(); expect(res.status).toBe(200); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.backups).toEqual([]); expect(body.data.nextScheduled).toBeNull(); }); @@ -36,7 +36,7 @@ describe.sequential("Backup API routes", () => { const body = await res.json(); expect(res.status).toBe(200); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.backups).toHaveLength(1); expect(body.data.backups[0]).toHaveProperty("filename"); expect(body.data.backups[0]).toHaveProperty("type", "manual"); @@ -51,7 +51,7 @@ describe.sequential("Backup API routes", () => { const body = await res.json(); expect(res.status).toBe(200); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.type).toBe("manual"); expect(body.data.filename).toMatch( /^jobs_manual_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}\.db$/, @@ -67,8 +67,8 @@ describe.sequential("Backup API routes", () => { const body = await res.json(); expect(res.status).toBe(500); - expect(body.success).toBe(false); - expect(body.error).toContain("Database file not found"); + expect(body.ok).toBe(false); + expect(body.error.message).toContain("Database file not found"); }); }); @@ -88,8 +88,8 @@ describe.sequential("Backup API routes", () => { const deleteBody = await deleteRes.json(); expect(deleteRes.status).toBe(200); - expect(deleteBody.success).toBe(true); - expect(deleteBody.message).toContain("deleted successfully"); + expect(deleteBody.ok).toBe(true); + expect(deleteBody.data.message).toContain("deleted successfully"); // Verify it's gone const listRes = await fetch(`${baseUrl}/api/backups`); @@ -104,8 +104,8 @@ describe.sequential("Backup API routes", () => { const body = await res.json(); expect(res.status).toBe(404); - expect(body.success).toBe(false); - expect(body.error).toContain("not found"); + expect(body.ok).toBe(false); + expect(body.error.message).toContain("not found"); }); it("should return 400 for invalid filename", async () => { @@ -115,8 +115,8 @@ describe.sequential("Backup API routes", () => { const body = await res.json(); expect(res.status).toBe(400); - expect(body.success).toBe(false); - expect(body.error).toContain("Invalid"); + expect(body.ok).toBe(false); + expect(body.error.message).toContain("Invalid"); }); }); }); diff --git a/orchestrator/src/server/api/routes/backup.ts b/orchestrator/src/server/api/routes/backup.ts index 6a797ce..b286289 100644 --- a/orchestrator/src/server/api/routes/backup.ts +++ b/orchestrator/src/server/api/routes/backup.ts @@ -1,3 +1,4 @@ +import { logger } from "@infra/logger"; import { createBackup, deleteBackup, @@ -25,7 +26,7 @@ backupRouter.get("/", async (_req: Request, res: Response) => { }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - console.error("❌ [backup-api] Failed to list backups:", error); + logger.error("Failed to list backups", error); res.status(500).json({ success: false, error: message }); } }); @@ -49,7 +50,7 @@ backupRouter.post("/", async (_req: Request, res: Response) => { }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - console.error("❌ [backup-api] Failed to create backup:", error); + logger.error("Failed to create backup", error); res.status(500).json({ success: false, error: message }); } }); @@ -77,10 +78,10 @@ backupRouter.delete("/:filename", async (req: Request, res: Response) => { }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - console.error( - `❌ [backup-api] Failed to delete backup ${req.params.filename}:`, + logger.error("Failed to delete backup", { + filename: req.params.filename, error, - ); + }); if (message.includes("not found")) { res.status(404).json({ success: false, error: message }); diff --git a/orchestrator/src/server/api/routes/database.test.ts b/orchestrator/src/server/api/routes/database.test.ts index 22b231e..b9402d7 100644 --- a/orchestrator/src/server/api/routes/database.test.ts +++ b/orchestrator/src/server/api/routes/database.test.ts @@ -28,7 +28,7 @@ describe.sequential("Database API routes", () => { const res = await fetch(`${baseUrl}/api/database`, { method: "DELETE" }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.jobsDeleted).toBe(1); }); }); diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts index 6cd4be9..4070f4f 100644 --- a/orchestrator/src/server/api/routes/jobs.test.ts +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -28,7 +28,7 @@ describe.sequential("Jobs API routes", () => { const listRes = await fetch(`${baseUrl}/api/jobs`); const listBody = await listRes.json(); - expect(listBody.success).toBe(true); + expect(listBody.ok).toBe(true); expect(listBody.data.total).toBe(1); expect(listBody.data.jobs[0].id).toBe(job.id); @@ -92,7 +92,7 @@ describe.sequential("Jobs API routes", () => { method: "POST", }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.status).toBe("applied"); expect(body.data.notionPageId).toBe("page-123"); expect(body.data.appliedAt).toBeTruthy(); @@ -135,7 +135,7 @@ describe.sequential("Jobs API routes", () => { }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.suitabilityScore).toBe(77); expect(body.data.suitabilityReason).toBe("Updated fit"); }); @@ -165,7 +165,7 @@ describe.sequential("Jobs API routes", () => { }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.sponsorMatchScore).toBe(100); expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR"); }); @@ -192,7 +192,7 @@ describe.sequential("Jobs API routes", () => { body: JSON.stringify({ toStage: "applied" }), }); const body1 = await trans1.json(); - expect(body1.success).toBe(true); + expect(body1.ok).toBe(true); expect(body1.data.toStage).toBe("applied"); const eventId = body1.data.id; @@ -209,7 +209,7 @@ describe.sequential("Jobs API routes", () => { // 3. Get events const eventsRes = await fetch(`${baseUrl}/api/jobs/${jobId}/events`); const eventsBody = await eventsRes.json(); - expect(eventsBody.success).toBe(true); + expect(eventsBody.ok).toBe(true); expect(eventsBody.data).toHaveLength(2); expect(eventsBody.data[0].toStage).toBe("applied"); expect(eventsBody.data[1].toStage).toBe("recruiter_screen"); @@ -252,7 +252,7 @@ describe.sequential("Jobs API routes", () => { // 1. Initial state const res1 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`); const body1 = await res1.json(); - expect(body1.success).toBe(true); + expect(body1.ok).toBe(true); expect(body1.data).toEqual([]); // 2. Insert a task @@ -297,7 +297,7 @@ describe.sequential("Jobs API routes", () => { body: JSON.stringify({ outcome: "rejected" }), }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.outcome).toBe("rejected"); expect(body.data.closedAt).toBeTruthy(); }); diff --git a/orchestrator/src/server/api/routes/jobs.ts b/orchestrator/src/server/api/routes/jobs.ts index 688dbdc..5bcb3ed 100644 --- a/orchestrator/src/server/api/routes/jobs.ts +++ b/orchestrator/src/server/api/routes/jobs.ts @@ -1,3 +1,5 @@ +import { logger } from "@infra/logger"; +import { sanitizeWebhookPayload } from "@infra/sanitize"; import { APPLICATION_OUTCOMES, APPLICATION_STAGES, @@ -8,7 +10,6 @@ import { } from "@shared/types"; import { type Request, type Response, Router } from "express"; import { z } from "zod"; - import { generateFinalPdf, processJob, @@ -49,23 +50,35 @@ async function notifyJobCompleteWebhook(job: Job) { const secret = process.env.WEBHOOK_SECRET; if (secret) headers.Authorization = `Bearer ${secret}`; + const payload = sanitizeWebhookPayload({ + event: "job.completed", + sentAt: new Date().toISOString(), + job: { + id: job.id, + source: job.source, + title: job.title, + employer: job.employer, + status: job.status, + suitabilityScore: job.suitabilityScore, + sponsorMatchScore: job.sponsorMatchScore, + }, + }); + const response = await fetch(webhookUrl, { method: "POST", headers, - body: JSON.stringify({ - event: "job.completed", - sentAt: new Date().toISOString(), - job, - }), + body: JSON.stringify(payload), }); if (!response.ok) { - console.warn( - `ƒsÿ‹,? Job complete webhook POST failed (${response.status}): ${await response.text()}`, - ); + logger.warn("Job complete webhook POST failed", { + status: response.status, + response: (await response.text().catch(() => "")).slice(0, 200), + jobId: job.id, + }); } } catch (error) { - console.warn("ƒsÿ‹,? Job complete webhook POST failed:", error); + logger.warn("Job complete webhook POST failed", { jobId: job.id, error }); } } @@ -129,7 +142,7 @@ jobsRouter.get("/", async (req: Request, res: Response) => { const stats = await jobsRepo.getJobStats(); const response: ApiResponse = { - success: true, + ok: true, data: { jobs, total: jobs.length, @@ -493,7 +506,9 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => { }); if (updatedJob) { - notifyJobCompleteWebhook(updatedJob).catch(console.warn); + notifyJobCompleteWebhook(updatedJob).catch((error) => { + logger.warn("Job complete webhook dispatch failed", error); + }); } if (!updatedJob) { diff --git a/orchestrator/src/server/api/routes/manual-jobs.test.ts b/orchestrator/src/server/api/routes/manual-jobs.test.ts index abe94e5..67670ac 100644 --- a/orchestrator/src/server/api/routes/manual-jobs.test.ts +++ b/orchestrator/src/server/api/routes/manual-jobs.test.ts @@ -58,7 +58,7 @@ describe.sequential("Manual jobs API routes", () => { body: JSON.stringify({ jobDescription: "Role description" }), }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.job.title).toBe("Backend Engineer"); }); @@ -81,7 +81,7 @@ describe.sequential("Manual jobs API routes", () => { }), }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.source).toBe("manual"); expect(body.data.jobUrl).toMatch(/^manual:\/\//); await new Promise((resolve) => setTimeout(resolve, 25)); diff --git a/orchestrator/src/server/api/routes/manual-jobs.ts b/orchestrator/src/server/api/routes/manual-jobs.ts index 63ffffc..b91c029 100644 --- a/orchestrator/src/server/api/routes/manual-jobs.ts +++ b/orchestrator/src/server/api/routes/manual-jobs.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { logger } from "@infra/logger"; import type { ApiResponse, ManualJobFetchResponse, @@ -154,7 +155,7 @@ manualJobsRouter.post("/fetch", async (req: Request, res: Response) => { } const result: ApiResponse = { - success: true, + ok: true, data: { content: enrichedContent, url: input.url, @@ -185,7 +186,7 @@ manualJobsRouter.post("/infer", async (req: Request, res: Response) => { const result = await inferManualJobDetails(input.jobDescription); const response: ApiResponse = { - success: true, + ok: true, data: { job: result.job, warning: result.warning ?? null, @@ -254,10 +255,10 @@ manualJobsRouter.post("/import", async (req: Request, res: Response) => { suitabilityReason: reason, }); } catch (error) { - console.warn("Manual job scoring failed:", error); + logger.warn("Manual job scoring failed", error); } })().catch((error) => { - console.warn("Manual job scoring task failed to start:", error); + logger.warn("Manual job scoring task failed to start", error); }); res.json({ success: true, data: createdJob }); diff --git a/orchestrator/src/server/api/routes/onboarding.test.ts b/orchestrator/src/server/api/routes/onboarding.test.ts index e73e491..046b8b3 100644 --- a/orchestrator/src/server/api/routes/onboarding.test.ts +++ b/orchestrator/src/server/api/routes/onboarding.test.ts @@ -30,7 +30,7 @@ describe.sequential("Onboarding API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.valid).toBe(false); expect(body.data.message).toContain("missing"); }); @@ -68,7 +68,7 @@ describe.sequential("Onboarding API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); // Should be invalid because the key is fake expect(body.data.valid).toBe(false); }); @@ -84,7 +84,7 @@ describe.sequential("Onboarding API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.valid).toBe(false); expect(body.data.message).toContain("missing"); }); @@ -132,7 +132,7 @@ describe.sequential("Onboarding API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); // Should be invalid because credentials are fake expect(body.data.valid).toBe(false); }); @@ -157,7 +157,7 @@ describe.sequential("Onboarding API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.valid).toBe(false); expect(body.data.message).toContain("No base resume selected"); }); diff --git a/orchestrator/src/server/api/routes/pipeline.test.ts b/orchestrator/src/server/api/routes/pipeline.test.ts index b2f3cbe..3f89822 100644 --- a/orchestrator/src/server/api/routes/pipeline.test.ts +++ b/orchestrator/src/server/api/routes/pipeline.test.ts @@ -19,7 +19,7 @@ describe.sequential("Pipeline API routes", () => { it("reports pipeline status", async () => { const res = await fetch(`${baseUrl}/api/pipeline/status`); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.isRunning).toBe(false); expect(body.data.lastRun).toBeNull(); }); @@ -39,7 +39,7 @@ describe.sequential("Pipeline API routes", () => { body: JSON.stringify({ topN: 5, sources: ["gradcracker"] }), }); const runBody = await runRes.json(); - expect(runBody.success).toBe(true); + expect(runBody.ok).toBe(true); expect(runPipeline).toHaveBeenCalledWith({ topN: 5, sources: ["gradcracker"], diff --git a/orchestrator/src/server/api/routes/pipeline.ts b/orchestrator/src/server/api/routes/pipeline.ts index 4b9af3f..7c6a612 100644 --- a/orchestrator/src/server/api/routes/pipeline.ts +++ b/orchestrator/src/server/api/routes/pipeline.ts @@ -1,3 +1,5 @@ +import { logger } from "@infra/logger"; +import { runWithRequestContext } from "@infra/request-context"; import type { ApiResponse, PipelineStatusResponse } from "@shared/types"; import { type Request, type Response, Router } from "express"; import { z } from "zod"; @@ -19,7 +21,7 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => { const lastRun = await pipelineRepo.getLatestPipelineRun(); const response: ApiResponse = { - success: true, + ok: true, data: { isRunning, lastRun, @@ -30,7 +32,9 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => { res.json(response); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + res + .status(500) + .json({ ok: false, error: { code: "INTERNAL_ERROR", message } }); } }); @@ -70,10 +74,12 @@ pipelineRouter.get("/progress", (req: Request, res: Response) => { pipelineRouter.get("/runs", async (_req: Request, res: Response) => { try { const runs = await pipelineRepo.getRecentPipelineRuns(20); - res.json({ success: true, data: runs }); + res.json({ ok: true, data: runs }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + res + .status(500) + .json({ ok: false, error: { code: "INTERNAL_ERROR", message } }); } }); @@ -94,17 +100,26 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => { const config = runPipelineSchema.parse(req.body); // Start pipeline in background - runPipeline(config).catch(console.error); + runWithRequestContext({}, () => { + runPipeline(config).catch((error) => { + logger.error("Background pipeline run failed", error); + }); + }); res.json({ - success: true, + ok: true, data: { message: "Pipeline started" }, }); } catch (error) { if (error instanceof z.ZodError) { - return res.status(400).json({ success: false, error: error.message }); + return res.status(400).json({ + ok: false, + error: { code: "INVALID_REQUEST", message: error.message }, + }); } const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + res + .status(500) + .json({ ok: false, error: { code: "INTERNAL_ERROR", message } }); } }); diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index 4326ee0..1d03d16 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -83,7 +83,7 @@ describe.sequential("Profile API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(Array.isArray(body.data)).toBe(true); expect(body.data.length).toBe(2); }); @@ -97,8 +97,8 @@ describe.sequential("Profile API routes", () => { const body = await res.json(); expect(res.ok).toBe(false); - expect(body.success).toBe(false); - expect(body.error).toContain("Base resume not configured"); + expect(body.ok).toBe(false); + expect(body.error.message).toContain("Base resume not configured"); }); }); @@ -114,7 +114,7 @@ describe.sequential("Profile API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data).toEqual(mockProfile); }); @@ -127,8 +127,8 @@ describe.sequential("Profile API routes", () => { const body = await res.json(); expect(res.ok).toBe(false); - expect(body.success).toBe(false); - expect(body.error).toContain("Base resume not configured"); + expect(body.ok).toBe(false); + expect(body.error.message).toContain("Base resume not configured"); }); }); @@ -140,7 +140,7 @@ describe.sequential("Profile API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.exists).toBe(false); expect(body.data.error).toContain("No base resume selected"); }); @@ -156,7 +156,7 @@ describe.sequential("Profile API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.exists).toBe(true); expect(body.data.error).toBeNull(); }); @@ -169,7 +169,7 @@ describe.sequential("Profile API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.exists).toBe(false); expect(body.data.error).toContain("credentials not configured"); }); @@ -185,7 +185,7 @@ describe.sequential("Profile API routes", () => { const body = await res.json(); expect(res.ok).toBe(true); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.exists).toBe(false); expect(body.data.error).toContain("empty or invalid"); }); diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index c2329be..d629f05 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -24,7 +24,7 @@ describe.sequential("Settings API routes", () => { it("returns settings with defaults", async () => { const res = await fetch(`${baseUrl}/api/settings`); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.defaultModel).toBe("test-model"); expect(Array.isArray(body.data.searchTerms)).toBe(true); expect(body.data.rxresumeEmail).toBe("resume@example.com"); @@ -51,7 +51,7 @@ describe.sequential("Settings API routes", () => { }), }); const patchBody = await patchRes.json(); - expect(patchBody.success).toBe(true); + expect(patchBody.ok).toBe(true); expect(patchBody.data.searchTerms).toEqual(["engineer"]); expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]); expect(patchBody.data.rxresumeEmail).toBe("updated@example.com"); @@ -70,7 +70,7 @@ describe.sequential("Settings API routes", () => { }); expect(res.status).toBe(400); const body = await res.json(); - expect(body.success).toBe(false); - expect(body.error).toContain("Username is required"); + expect(body.ok).toBe(false); + expect(body.error.message).toContain("Username is required"); }); }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index dbc3fe1..8db5837 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -1,3 +1,4 @@ +import { logger } from "@infra/logger"; import { setBackupSettings } from "@server/services/backup/index"; import { extractProjectsFromProfile } from "@server/services/resumeProjects"; import { @@ -72,7 +73,7 @@ settingsRouter.get("/rx-resumes", async (_req: Request, res: Response) => { return; } const message = error instanceof Error ? error.message : "Unknown error"; - console.error(`❌ Failed to fetch Reactive Resumes: ${message}`); + logger.error("Failed to fetch Reactive Resumes", { message }); res.status(500).json({ success: false, error: message }); } }); @@ -103,7 +104,7 @@ settingsRouter.get( return; } const message = error instanceof Error ? error.message : "Unknown error"; - console.error(`❌ Failed to fetch RxResume projects: ${message}`); + logger.error("Failed to fetch RxResume projects", { message }); res.status(500).json({ success: false, error: message }); } }, diff --git a/orchestrator/src/server/api/routes/ukvisajobs.test.ts b/orchestrator/src/server/api/routes/ukvisajobs.test.ts index 473593c..b0f3a8e 100644 --- a/orchestrator/src/server/api/routes/ukvisajobs.test.ts +++ b/orchestrator/src/server/api/routes/ukvisajobs.test.ts @@ -47,7 +47,7 @@ describe.sequential("UK Visa Jobs API routes", () => { body: JSON.stringify({ query: "engineer" }), }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.totalPages).toBe(2); expect(fetchUkVisaJobsPage).toHaveBeenCalledWith({ searchKeyword: "engineer", @@ -87,7 +87,7 @@ describe.sequential("UK Visa Jobs API routes", () => { }), }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.created).toBe(1); expect(body.data.skipped).toBe(1); }); diff --git a/orchestrator/src/server/api/routes/ukvisajobs.ts b/orchestrator/src/server/api/routes/ukvisajobs.ts index c89aecd..c781122 100644 --- a/orchestrator/src/server/api/routes/ukvisajobs.ts +++ b/orchestrator/src/server/api/routes/ukvisajobs.ts @@ -70,7 +70,7 @@ ukVisaJobsRouter.post("/search", async (req: Request, res: Response) => { ); const response: ApiResponse = { - success: true, + ok: true, data: { jobs: result.jobs, totalJobs: result.totalJobs, @@ -133,7 +133,7 @@ ukVisaJobsRouter.post("/import", async (req: Request, res: Response) => { const result = await jobsRepo.bulkCreateJobs(jobs); const response: ApiResponse = { - success: true, + ok: true, data: { created: result.created, skipped: result.skipped, diff --git a/orchestrator/src/server/api/routes/visa-sponsors.test.ts b/orchestrator/src/server/api/routes/visa-sponsors.test.ts index 2c746b7..19c2b20 100644 --- a/orchestrator/src/server/api/routes/visa-sponsors.test.ts +++ b/orchestrator/src/server/api/routes/visa-sponsors.test.ts @@ -35,7 +35,7 @@ describe.sequential("Visa sponsors API routes", () => { const statusRes = await fetch(`${baseUrl}/api/visa-sponsors/status`); const statusBody = await statusRes.json(); - expect(statusBody.success).toBe(true); + expect(statusBody.ok).toBe(true); expect(statusBody.data.totalSponsors).toBe(0); const updateRes = await fetch(`${baseUrl}/api/visa-sponsors/update`, { @@ -76,7 +76,7 @@ describe.sequential("Visa sponsors API routes", () => { body: JSON.stringify({ query: "Acme" }), }); const body = await res.json(); - expect(body.success).toBe(true); + expect(body.ok).toBe(true); expect(body.data.total).toBe(1); const orgRes = await fetch( diff --git a/orchestrator/src/server/api/routes/visa-sponsors.ts b/orchestrator/src/server/api/routes/visa-sponsors.ts index 09b2edd..5817b70 100644 --- a/orchestrator/src/server/api/routes/visa-sponsors.ts +++ b/orchestrator/src/server/api/routes/visa-sponsors.ts @@ -17,7 +17,7 @@ visaSponsorsRouter.get("/status", async (_req: Request, res: Response) => { try { const status = visaSponsors.getStatus(); const response: ApiResponse = { - success: true, + ok: true, data: status, }; res.json(response); @@ -46,7 +46,7 @@ visaSponsorsRouter.post("/search", async (req: Request, res: Response) => { }); const response: ApiResponse = { - success: true, + ok: true, data: { results, query: input.query, diff --git a/orchestrator/src/server/api/routes/webhook.test.ts b/orchestrator/src/server/api/routes/webhook.test.ts index 04955ea..221efbf 100644 --- a/orchestrator/src/server/api/routes/webhook.test.ts +++ b/orchestrator/src/server/api/routes/webhook.test.ts @@ -29,7 +29,7 @@ describe.sequential("Webhook API routes", () => { headers: { Authorization: "Bearer secret" }, }); const goodBody = await goodRes.json(); - expect(goodBody.success).toBe(true); + expect(goodBody.ok).toBe(true); expect(goodBody.data.message).toBe("Pipeline triggered"); }); }); diff --git a/orchestrator/src/server/api/routes/webhook.ts b/orchestrator/src/server/api/routes/webhook.ts index 4cc3cb9..b2d1a05 100644 --- a/orchestrator/src/server/api/routes/webhook.ts +++ b/orchestrator/src/server/api/routes/webhook.ts @@ -1,3 +1,5 @@ +import { logger } from "@infra/logger"; +import { runWithRequestContext } from "@infra/request-context"; import { type Request, type Response, Router } from "express"; import { runPipeline } from "../../pipeline/index"; @@ -12,15 +14,22 @@ webhookRouter.post("/trigger", async (req: Request, res: Response) => { const expectedToken = process.env.WEBHOOK_SECRET; if (expectedToken && authHeader !== `Bearer ${expectedToken}`) { - return res.status(401).json({ success: false, error: "Unauthorized" }); + return res.status(401).json({ + ok: false, + error: { code: "UNAUTHORIZED", message: "Unauthorized" }, + }); } try { // Start pipeline in background - runPipeline().catch(console.error); + runWithRequestContext({}, () => { + runPipeline().catch((error) => { + logger.error("Webhook-triggered pipeline run failed", error); + }); + }); res.json({ - success: true, + ok: true, data: { message: "Pipeline triggered", triggeredAt: new Date().toISOString(), @@ -28,6 +37,8 @@ webhookRouter.post("/trigger", async (req: Request, res: Response) => { }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + res + .status(500) + .json({ ok: false, error: { code: "INTERNAL_ERROR", message } }); } }); diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts index e1d75bf..be14437 100644 --- a/orchestrator/src/server/app.ts +++ b/orchestrator/src/server/app.ts @@ -5,6 +5,15 @@ import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { unauthorized } from "@infra/errors"; +import { + apiErrorHandler, + fail, + legacyApiResponseShim, + notFoundApiHandler, + requestContextMiddleware, +} from "@infra/http"; +import { logger } from "@infra/logger"; import cors from "cors"; import express from "express"; import { apiRouter } from "./api/index"; @@ -67,7 +76,7 @@ function createBasicAuthGuard() { if (!enabled || !requiresAuth(req.method, req.path)) return next(); if (isAuthorized(req)) return next(); res.setHeader("WWW-Authenticate", 'Basic realm="Job Ops"'); - res.status(401).send("Authentication required"); + fail(res, unauthorized("Authentication required")); }; return { @@ -82,16 +91,21 @@ export function createApp() { const authGuard = createBasicAuthGuard(); app.use(cors()); + app.use(requestContextMiddleware()); app.use(express.json({ limit: "5mb" })); + app.use(legacyApiResponseShim()); // Logging middleware app.use((req, res, next) => { const start = Date.now(); res.on("finish", () => { const duration = Date.now() - start; - console.log( - `${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`, - ); + logger.info("HTTP request completed", { + method: req.method, + path: req.path, + status: res.statusCode, + durationMs: duration, + }); }); next(); }); @@ -101,6 +115,7 @@ export function createApp() { // API routes app.use("/api", apiRouter); + app.use(notFoundApiHandler()); // Serve static files for generated PDFs const pdfDir = join(getDataDir(), "pdfs"); @@ -132,5 +147,7 @@ export function createApp() { }); } + app.use(apiErrorHandler); + return app; } diff --git a/orchestrator/src/server/infra/errors.ts b/orchestrator/src/server/infra/errors.ts new file mode 100644 index 0000000..cca4582 --- /dev/null +++ b/orchestrator/src/server/infra/errors.ts @@ -0,0 +1,128 @@ +import { ZodError } from "zod"; + +export type AppErrorCode = + | "INVALID_REQUEST" + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "REQUEST_TIMEOUT" + | "CONFLICT" + | "UNPROCESSABLE_ENTITY" + | "UPSTREAM_ERROR" + | "SERVICE_UNAVAILABLE" + | "INTERNAL_ERROR"; + +const DEFAULT_CODE_BY_STATUS: Record = { + 400: "INVALID_REQUEST", + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 408: "REQUEST_TIMEOUT", + 409: "CONFLICT", + 422: "UNPROCESSABLE_ENTITY", + 500: "INTERNAL_ERROR", + 502: "UPSTREAM_ERROR", + 503: "SERVICE_UNAVAILABLE", +}; + +export class AppError extends Error { + status: number; + code: AppErrorCode; + details?: unknown; + + constructor(args: { + message: string; + status?: number; + code?: AppErrorCode; + details?: unknown; + cause?: unknown; + }) { + super(args.message, { cause: args.cause }); + this.name = "AppError"; + this.status = args.status ?? 500; + this.code = args.code ?? statusToCode(this.status); + this.details = args.details; + } +} + +export function statusToCode(status: number): AppErrorCode { + return DEFAULT_CODE_BY_STATUS[status] ?? "INTERNAL_ERROR"; +} + +export function badRequest(message: string, details?: unknown): AppError { + return new AppError({ + status: 400, + code: "INVALID_REQUEST", + message, + details, + }); +} + +export function unauthorized(message = "Unauthorized"): AppError { + return new AppError({ status: 401, code: "UNAUTHORIZED", message }); +} + +export function forbidden(message = "Forbidden"): AppError { + return new AppError({ status: 403, code: "FORBIDDEN", message }); +} + +export function notFound(message = "Not found"): AppError { + return new AppError({ status: 404, code: "NOT_FOUND", message }); +} + +export function requestTimeout(message = "Request timed out"): AppError { + return new AppError({ status: 408, code: "REQUEST_TIMEOUT", message }); +} + +export function conflict(message: string): AppError { + return new AppError({ status: 409, code: "CONFLICT", message }); +} + +export function unprocessableEntity( + message: string, + details?: unknown, +): AppError { + return new AppError({ + status: 422, + code: "UNPROCESSABLE_ENTITY", + message, + details, + }); +} + +export function upstreamError(message: string, details?: unknown): AppError { + return new AppError({ + status: 502, + code: "UPSTREAM_ERROR", + message, + details, + }); +} + +export function serviceUnavailable(message: string): AppError { + return new AppError({ status: 503, code: "SERVICE_UNAVAILABLE", message }); +} + +export function toAppError(error: unknown): AppError { + if (error instanceof AppError) return error; + if (error instanceof ZodError) { + return badRequest(error.message, error.flatten()); + } + if (error instanceof Error && error.name === "AbortError") { + return requestTimeout("Request timed out"); + } + if (error instanceof Error) { + return new AppError({ + status: 500, + code: "INTERNAL_ERROR", + message: error.message || "Internal server error", + cause: error, + }); + } + return new AppError({ + status: 500, + code: "INTERNAL_ERROR", + message: "Internal server error", + details: error, + }); +} diff --git a/orchestrator/src/server/infra/http.ts b/orchestrator/src/server/infra/http.ts new file mode 100644 index 0000000..e804f93 --- /dev/null +++ b/orchestrator/src/server/infra/http.ts @@ -0,0 +1,154 @@ +import crypto from "node:crypto"; +import type { ApiResponse } from "@shared/types"; +import type { + ErrorRequestHandler, + NextFunction, + Request, + RequestHandler, + Response, +} from "express"; +import type { AppError } from "./errors"; +import { notFound, statusToCode, toAppError } from "./errors"; +import { logger } from "./logger"; +import { getRequestId, runWithRequestContext } from "./request-context"; +import { sanitizeUnknown } from "./sanitize"; + +function getResponseRequestId(res: Response): string { + return ( + (res.getHeader("x-request-id") as string | undefined) ?? + getRequestId() ?? + "unknown" + ); +} + +export function ok(res: Response, data: T, status = 200): void { + const payload: ApiResponse = { + ok: true, + data, + meta: { requestId: getResponseRequestId(res) }, + }; + res.status(status).json(payload); +} + +export function fail(res: Response, error: AppError): void { + const payload: ApiResponse = { + ok: false, + error: { + code: error.code, + message: error.message, + ...(error.details !== undefined + ? { details: sanitizeUnknown(error.details) } + : {}), + }, + meta: { requestId: getResponseRequestId(res) }, + }; + res.status(error.status).json(payload); +} + +export function asyncRoute( + handler: ( + req: Request, + res: Response, + next: NextFunction, + ) => Promise, +): RequestHandler { + return (req, res, next) => { + Promise.resolve(handler(req, res, next)).catch(next); + }; +} + +export function requestContextMiddleware(): RequestHandler { + return (req, res, next) => { + const requestIdHeader = req.header("x-request-id")?.trim(); + const requestId = + requestIdHeader && requestIdHeader.length > 0 + ? requestIdHeader + : crypto.randomUUID(); + + res.setHeader("x-request-id", requestId); + runWithRequestContext({ requestId }, () => next()); + }; +} + +export function legacyApiResponseShim(): RequestHandler { + return (req, res, next) => { + if (!req.path.startsWith("/api")) return next(); + + const originalJson = res.json.bind(res); + res.json = ((body: unknown) => { + if (!body || typeof body !== "object") return originalJson(body); + const payload = body as Record; + if ("ok" in payload) { + if (!("meta" in payload)) { + return originalJson({ + ...payload, + meta: { requestId: getResponseRequestId(res) }, + }); + } + return originalJson(body); + } + + if (typeof payload.success === "boolean") { + const requestId = getResponseRequestId(res); + if (payload.success) { + let data: unknown = payload.data; + if (data === undefined && payload.message !== undefined) { + data = { message: payload.message }; + } + return originalJson({ + ok: true, + data: data ?? null, + meta: { requestId }, + } satisfies ApiResponse); + } + + const status = res.statusCode >= 400 ? res.statusCode : 500; + const rawError = payload.error; + const message = + typeof rawError === "string" + ? rawError + : typeof payload.message === "string" + ? payload.message + : "Request failed"; + const details = + rawError && typeof rawError === "object" + ? (rawError as { details?: unknown }).details + : payload.details; + + return originalJson({ + ok: false, + error: { + code: statusToCode(status), + message, + ...(details !== undefined + ? { details: sanitizeUnknown(details) } + : {}), + }, + meta: { requestId }, + } satisfies ApiResponse); + } + + return originalJson(body); + }) as Response["json"]; + + next(); + }; +} + +export function notFoundApiHandler(): RequestHandler { + return (req, _res, next) => { + if (!req.path.startsWith("/api")) return next(); + next(notFound(`Route not found: ${req.method} ${req.path}`)); + }; +} + +export const apiErrorHandler: ErrorRequestHandler = (err, _req, res, _next) => { + const appError = toAppError(err); + logger.error(appError.message, { + status: appError.status, + code: appError.code, + details: appError.details, + cause: appError.cause, + }); + fail(res, appError); +}; diff --git a/orchestrator/src/server/infra/logger.ts b/orchestrator/src/server/infra/logger.ts new file mode 100644 index 0000000..fc6cbe9 --- /dev/null +++ b/orchestrator/src/server/infra/logger.ts @@ -0,0 +1,74 @@ +import { getRequestContext } from "./request-context"; +import { sanitizeError, sanitizeUnknown } from "./sanitize"; + +type LogLevel = "debug" | "info" | "warn" | "error"; + +const levelPriority: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +function resolveMinLevel(): LogLevel { + const raw = process.env.LOG_LEVEL?.toLowerCase(); + if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") { + return raw; + } + return "info"; +} + +const minLevel = resolveMinLevel(); + +export class Logger { + constructor(private readonly context: Record = {}) {} + + child(context: Record): Logger { + return new Logger({ ...this.context, ...context }); + } + + debug(message: string, meta?: unknown): void { + this.log("debug", message, meta); + } + + info(message: string, meta?: unknown): void { + this.log("info", message, meta); + } + + warn(message: string, meta?: unknown): void { + this.log("warn", message, meta); + } + + error(message: string, meta?: unknown): void { + this.log("error", message, meta); + } + + private log(level: LogLevel, message: string, meta?: unknown): void { + if (levelPriority[level] < levelPriority[minLevel]) return; + + const requestContext = getRequestContext(); + const payload: Record = { + ts: new Date().toISOString(), + level, + msg: message, + ...this.context, + ...(requestContext ?? {}), + }; + + if (meta !== undefined) { + payload.meta = + meta instanceof Error ? sanitizeError(meta) : sanitizeUnknown(meta); + } + + const line = JSON.stringify(payload); + if (level === "error") { + console.error(line); + } else if (level === "warn") { + console.warn(line); + } else { + console.log(line); + } + } +} + +export const logger = new Logger(); diff --git a/orchestrator/src/server/infra/request-context.ts b/orchestrator/src/server/infra/request-context.ts new file mode 100644 index 0000000..afd71d1 --- /dev/null +++ b/orchestrator/src/server/infra/request-context.ts @@ -0,0 +1,30 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export type RequestContext = { + requestId: string; + pipelineRunId?: string; + jobId?: string; +}; + +const storage = new AsyncLocalStorage(); + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function runWithRequestContext( + context: Partial, + fn: () => T, +): T { + const current = storage.getStore(); + const merged: RequestContext = { + requestId: context.requestId ?? current?.requestId ?? "unknown", + ...(current ?? {}), + ...context, + }; + return storage.run(merged, fn); +} + +export function getRequestId(): string | undefined { + return storage.getStore()?.requestId; +} diff --git a/orchestrator/src/server/infra/sanitize.ts b/orchestrator/src/server/infra/sanitize.ts new file mode 100644 index 0000000..54b4cfb --- /dev/null +++ b/orchestrator/src/server/infra/sanitize.ts @@ -0,0 +1,114 @@ +const REDACTED = "[REDACTED]"; + +const SENSITIVE_KEY_PATTERN = + /(authorization|cookie|password|pass|secret|token|api.?key|credential|set-cookie|proxy-authorization|x-api-key)/i; + +const DEFAULT_MAX_STRING = 800; +const DEFAULT_MAX_DEPTH = 5; +const DEFAULT_MAX_ITEMS = 30; + +export function redactString(value: string, max = DEFAULT_MAX_STRING): string { + if (value.length <= max) return value; + return `${value.slice(0, max)}…(truncated ${value.length - max} chars)`; +} + +export function sanitizeUnknown( + value: unknown, + options: { depth?: number; maxItems?: number; maxString?: number } = {}, +): unknown { + const depth = options.depth ?? DEFAULT_MAX_DEPTH; + const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS; + const maxString = options.maxString ?? DEFAULT_MAX_STRING; + + if (value === null || value === undefined) return value; + if (typeof value === "string") return redactString(value, maxString); + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return value; + } + + if (value instanceof Error) { + return sanitizeError(value); + } + + if (depth <= 0) { + return "[TRUNCATED_DEPTH]"; + } + + if (Array.isArray(value)) { + const limited = value.slice(0, maxItems); + const out = limited.map((item) => + sanitizeUnknown(item, { + depth: depth - 1, + maxItems, + maxString, + }), + ); + if (value.length > maxItems) { + out.push(`[TRUNCATED_ITEMS ${value.length - maxItems}]`); + } + return out; + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + const out: Record = {}; + for (const [index, [key, entryValue]] of entries.entries()) { + if (index >= maxItems) { + out.__truncatedKeys = entries.length - maxItems; + break; + } + + if (SENSITIVE_KEY_PATTERN.test(key)) { + out[key] = REDACTED; + continue; + } + + out[key] = sanitizeUnknown(entryValue, { + depth: depth - 1, + maxItems, + maxString, + }); + } + return out; + } + + return String(value); +} + +export function sanitizeError(error: Error): Record { + const out: Record = { + name: error.name, + message: redactString(error.message), + }; + + const maybe = error as Error & { + status?: number; + body?: string; + details?: unknown; + cause?: unknown; + }; + if (typeof maybe.status === "number") out.status = maybe.status; + if (maybe.details !== undefined) out.details = sanitizeUnknown(maybe.details); + if (maybe.cause !== undefined) out.cause = sanitizeUnknown(maybe.cause); + if (maybe.body !== undefined) out.body = REDACTED; + if (error.stack) out.stack = redactString(error.stack, 1200); + return out; +} + +export function sanitizeWebhookPayload( + payload: unknown, +): Record { + const raw = sanitizeUnknown(payload, { + depth: 4, + maxItems: 20, + maxString: 300, + }); + return (raw && typeof raw === "object" ? raw : { value: raw }) as Record< + string, + unknown + >; +} diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index 865a8cf..8fa9401 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -8,6 +8,8 @@ */ import { join } from "node:path"; +import { logger } from "@infra/logger"; +import { runWithRequestContext } from "@infra/request-context"; import type { PipelineConfig } from "@shared/types"; import { getDataDir } from "../config/dataDir"; import * as jobsRepo from "../repositories/jobs"; @@ -71,91 +73,92 @@ export async function runPipeline( const mergedConfig = { ...DEFAULT_CONFIG, ...config }; const pipelineRun = await pipelineRepo.createPipelineRun(); - - console.log("🚀 Starting job pipeline..."); - console.log( - ` Config: topN=${mergedConfig.topN}, minScore=${mergedConfig.minSuitabilityScore} (manual processing)`, - ); - - try { - const profile = await loadProfileStep(); - - const { discoveredJobs } = await discoverJobsStep({ mergedConfig }); - - const { created } = await importJobsStep({ discoveredJobs }); - - await pipelineRepo.updatePipelineRun(pipelineRun.id, { - jobsDiscovered: created, + return runWithRequestContext({ pipelineRunId: pipelineRun.id }, async () => { + const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id }); + pipelineLogger.info("Starting pipeline run", { + topN: mergedConfig.topN, + minSuitabilityScore: mergedConfig.minSuitabilityScore, + sources: mergedConfig.sources, }); - const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ profile }); + try { + const profile = await loadProfileStep(); - const jobsToProcess = selectJobsStep({ - scoredJobs, - mergedConfig, - }); + const { discoveredJobs } = await discoverJobsStep({ mergedConfig }); - console.log("\n🏭 Auto-processing top jobs..."); - console.log( - ` Found ${jobsToProcess.length} candidates (score >= ${mergedConfig.minSuitabilityScore}, top ${mergedConfig.topN})`, - ); + const { created } = await importJobsStep({ discoveredJobs }); - const { processedCount } = await processJobsStep({ - jobsToProcess, - processJob, - }); + await pipelineRepo.updatePipelineRun(pipelineRun.id, { + jobsDiscovered: created, + }); - await pipelineRepo.updatePipelineRun(pipelineRun.id, { - status: "completed", - completedAt: new Date().toISOString(), - jobsProcessed: processedCount, - }); + const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ profile }); - console.log("\n🎉 Pipeline completed!"); - console.log(` Jobs discovered: ${created}`); - console.log(` Jobs processed: ${processedCount}`); + const jobsToProcess = selectJobsStep({ + scoredJobs, + mergedConfig, + }); - progressHelpers.complete(created, processedCount); + pipelineLogger.info("Selected jobs for processing", { + candidates: jobsToProcess.length, + }); - await notifyPipelineWebhookStep("pipeline.completed", { - pipelineRunId: pipelineRun.id, - jobsDiscovered: created, - jobsScored: unprocessedJobs.length, - jobsProcessed: processedCount, - }); - isPipelineRunning = false; + const { processedCount } = await processJobsStep({ + jobsToProcess, + processJob, + }); - return { - success: true, - jobsDiscovered: created, - jobsProcessed: processedCount, - }; - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; + await pipelineRepo.updatePipelineRun(pipelineRun.id, { + status: "completed", + completedAt: new Date().toISOString(), + jobsProcessed: processedCount, + }); - await pipelineRepo.updatePipelineRun(pipelineRun.id, { - status: "failed", - completedAt: new Date().toISOString(), - errorMessage: message, - }); + progressHelpers.complete(created, processedCount); + pipelineLogger.info("Pipeline run completed", { + jobsDiscovered: created, + jobsProcessed: processedCount, + }); - progressHelpers.failed(message); + await notifyPipelineWebhookStep("pipeline.completed", { + pipelineRunId: pipelineRun.id, + jobsDiscovered: created, + jobsScored: unprocessedJobs.length, + jobsProcessed: processedCount, + }); - await notifyPipelineWebhookStep("pipeline.failed", { - pipelineRunId: pipelineRun.id, - error: message, - }); - isPipelineRunning = false; + return { + success: true, + jobsDiscovered: created, + jobsProcessed: processedCount, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; - console.error("\n❌ Pipeline failed:", message); + await pipelineRepo.updatePipelineRun(pipelineRun.id, { + status: "failed", + completedAt: new Date().toISOString(), + errorMessage: message, + }); - return { - success: false, - jobsDiscovered: 0, - jobsProcessed: 0, - error: message, - }; - } + progressHelpers.failed(message); + pipelineLogger.error("Pipeline run failed", error); + + await notifyPipelineWebhookStep("pipeline.failed", { + pipelineRunId: pipelineRun.id, + error: message, + }); + + return { + success: false, + jobsDiscovered: 0, + jobsProcessed: 0, + error: message, + }; + } finally { + isPipelineRunning = false; + } + }); } export type ProcessJobOptions = { @@ -172,83 +175,87 @@ export async function summarizeJob( success: boolean; error?: string; }> { - console.log(`📝 Summarizing job ${jobId}...`); + return runWithRequestContext({ jobId }, async () => { + const jobLogger = logger.child({ jobId }); + jobLogger.info("Summarizing job"); + try { + const job = await jobsRepo.getJobById(jobId); + if (!job) return { success: false, error: "Job not found" }; - try { - const job = await jobsRepo.getJobById(jobId); - if (!job) return { success: false, error: "Job not found" }; + const profile = await getProfile(); - const profile = await getProfile(); + // 1. Generate Summary & Tailoring + let tailoredSummary = job.tailoredSummary; + let tailoredHeadline = job.tailoredHeadline; + let tailoredSkills = job.tailoredSkills; - // 1. Generate Summary & Tailoring - let tailoredSummary = job.tailoredSummary; - let tailoredHeadline = job.tailoredHeadline; - let tailoredSkills = job.tailoredSkills; - - if (!tailoredSummary || !tailoredHeadline || options?.force) { - console.log(" Generating tailoring (summary, headline, skills)..."); - const tailoringResult = await generateTailoring( - job.jobDescription || "", - profile, - ); - if (tailoringResult.success && tailoringResult.data) { - tailoredSummary = tailoringResult.data.summary; - tailoredHeadline = tailoringResult.data.headline; - tailoredSkills = JSON.stringify(tailoringResult.data.skills); - } else if (options?.force || !tailoredSummary || !tailoredHeadline) { - return { - success: false, - error: `Tailoring failed: ${tailoringResult.error || "unknown error"}`, - }; - } - } - - // 2. Suggest Projects - let selectedProjectIds = job.selectedProjectIds; - if (!selectedProjectIds || options?.force) { - console.log(" Suggesting projects..."); - try { - const { catalog, selectionItems } = extractProjectsFromProfile(profile); - const overrideResumeProjectsRaw = await getSetting("resumeProjects"); - const { resumeProjects } = resolveResumeProjectsSettings({ - catalog, - overrideRaw: overrideResumeProjectsRaw, - }); - - const locked = resumeProjects.lockedProjectIds; - const desiredCount = Math.max( - 0, - resumeProjects.maxProjects - locked.length, + if (!tailoredSummary || !tailoredHeadline || options?.force) { + jobLogger.info("Generating tailoring content"); + const tailoringResult = await generateTailoring( + job.jobDescription || "", + profile, ); - const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds); - const eligibleProjects = selectionItems.filter((p) => - eligibleSet.has(p.id), - ); - - const picked = await pickProjectIdsForJob({ - jobDescription: job.jobDescription || "", - eligibleProjects, - desiredCount, - }); - - selectedProjectIds = [...locked, ...picked].join(","); - } catch (_err) { - console.warn(" ⚠️ Failed to suggest projects, leaving empty"); + if (tailoringResult.success && tailoringResult.data) { + tailoredSummary = tailoringResult.data.summary; + tailoredHeadline = tailoringResult.data.headline; + tailoredSkills = JSON.stringify(tailoringResult.data.skills); + } else if (options?.force || !tailoredSummary || !tailoredHeadline) { + return { + success: false, + error: `Tailoring failed: ${tailoringResult.error || "unknown error"}`, + }; + } } + + // 2. Suggest Projects + let selectedProjectIds = job.selectedProjectIds; + if (!selectedProjectIds || options?.force) { + jobLogger.info("Selecting projects"); + try { + const { catalog, selectionItems } = + extractProjectsFromProfile(profile); + const overrideResumeProjectsRaw = await getSetting("resumeProjects"); + const { resumeProjects } = resolveResumeProjectsSettings({ + catalog, + overrideRaw: overrideResumeProjectsRaw, + }); + + const locked = resumeProjects.lockedProjectIds; + const desiredCount = Math.max( + 0, + resumeProjects.maxProjects - locked.length, + ); + const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds); + const eligibleProjects = selectionItems.filter((p) => + eligibleSet.has(p.id), + ); + + const picked = await pickProjectIdsForJob({ + jobDescription: job.jobDescription || "", + eligibleProjects, + desiredCount, + }); + + selectedProjectIds = [...locked, ...picked].join(","); + } catch (error) { + jobLogger.warn("Failed to suggest projects", error); + } + } + + await jobsRepo.updateJob(job.id, { + tailoredSummary: tailoredSummary ?? undefined, + tailoredHeadline: tailoredHeadline ?? undefined, + tailoredSkills: tailoredSkills ?? undefined, + selectedProjectIds: selectedProjectIds ?? undefined, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + jobLogger.error("Summarization failed", error); + return { success: false, error: message }; } - - await jobsRepo.updateJob(job.id, { - tailoredSummary: tailoredSummary ?? undefined, - tailoredHeadline: tailoredHeadline ?? undefined, - tailoredSkills: tailoredSkills ?? undefined, - selectedProjectIds: selectedProjectIds ?? undefined, - }); - - return { success: true }; - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - return { success: false, error: message }; - } + }); } /** @@ -261,43 +268,46 @@ export async function generateFinalPdf( success: boolean; error?: string; }> { - console.log(`📄 Generating final PDF for job ${jobId}...`); + return runWithRequestContext({ jobId }, async () => { + const jobLogger = logger.child({ jobId }); + jobLogger.info("Generating final PDF"); + try { + const job = await jobsRepo.getJobById(jobId); + if (!job) return { success: false, error: "Job not found" }; - try { - const job = await jobsRepo.getJobById(jobId); - if (!job) return { success: false, error: "Job not found" }; + // Mark as processing + await jobsRepo.updateJob(job.id, { status: "processing" }); - // Mark as processing - await jobsRepo.updateJob(job.id, { status: "processing" }); + const pdfResult = await generatePdf( + job.id, + { + summary: job.tailoredSummary || "", + headline: job.tailoredHeadline || "", + skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : [], + }, + job.jobDescription || "", + undefined, // deprecated baseResumePath parameter + job.selectedProjectIds, + ); - const pdfResult = await generatePdf( - job.id, - { - summary: job.tailoredSummary || "", - headline: job.tailoredHeadline || "", - skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : [], - }, - job.jobDescription || "", - undefined, // deprecated baseResumePath parameter - job.selectedProjectIds, - ); + if (!pdfResult.success) { + // Revert status if failed + await jobsRepo.updateJob(job.id, { status: "discovered" }); + return { success: false, error: pdfResult.error }; + } - if (!pdfResult.success) { - // Revert status if failed - await jobsRepo.updateJob(job.id, { status: "discovered" }); - return { success: false, error: pdfResult.error }; + await jobsRepo.updateJob(job.id, { + status: "ready", + pdfPath: pdfResult.pdfPath, + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + jobLogger.error("PDF generation failed", error); + return { success: false, error: message }; } - - await jobsRepo.updateJob(job.id, { - status: "ready", - pdfPath: pdfResult.pdfPath, - }); - - return { success: true }; - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - return { success: false, error: message }; - } + }); } /** diff --git a/orchestrator/src/server/pipeline/progress.ts b/orchestrator/src/server/pipeline/progress.ts index 1263587..edbbdd9 100644 --- a/orchestrator/src/server/pipeline/progress.ts +++ b/orchestrator/src/server/pipeline/progress.ts @@ -1,3 +1,5 @@ +import { logger } from "@infra/logger"; + /** * Pipeline progress tracking with Server-Sent Events. */ @@ -67,7 +69,7 @@ export function updateProgress(update: Partial): void { try { listener(currentProgress); } catch (error) { - console.error("Error in progress listener:", error); + logger.error("Error in progress listener", error); } } } diff --git a/orchestrator/src/server/pipeline/steps/discover-jobs.ts b/orchestrator/src/server/pipeline/steps/discover-jobs.ts index 2b6dfd6..749e9d0 100644 --- a/orchestrator/src/server/pipeline/steps/discover-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/discover-jobs.ts @@ -1,3 +1,4 @@ +import { logger } from "@infra/logger"; import type { CreateJobInput, PipelineConfig } from "@shared/types"; import * as jobsRepo from "../../repositories/jobs"; import * as settingsRepo from "../../repositories/settings"; @@ -12,7 +13,7 @@ export async function discoverJobsStep(args: { discoveredJobs: CreateJobInput[]; sourceErrors: string[]; }> { - console.log("\n🕷️ Running crawler..."); + logger.info("Running discovery step"); progressHelpers.startCrawling(); const discoveredJobs: CreateJobInput[] = []; @@ -149,7 +150,7 @@ export async function discoverJobsStep(args: { } if (sourceErrors.length > 0) { - console.warn(`⚠️ Some sources failed: ${sourceErrors.join("; ")}`); + logger.warn("Some discovery sources failed", { sourceErrors }); } progressHelpers.crawlingComplete(discoveredJobs.length); diff --git a/orchestrator/src/server/pipeline/steps/import-jobs.ts b/orchestrator/src/server/pipeline/steps/import-jobs.ts index db11395..aee279c 100644 --- a/orchestrator/src/server/pipeline/steps/import-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/import-jobs.ts @@ -1,3 +1,4 @@ +import { logger } from "@infra/logger"; import type { CreateJobInput } from "@shared/types"; import * as jobsRepo from "../../repositories/jobs"; import { progressHelpers } from "../progress"; @@ -5,11 +6,11 @@ import { progressHelpers } from "../progress"; export async function importJobsStep(args: { discoveredJobs: CreateJobInput[]; }): Promise<{ created: number; skipped: number }> { - console.log("\n💾 Importing jobs to database..."); + logger.info("Importing discovered jobs"); const { created, skipped } = await jobsRepo.bulkCreateJobs( args.discoveredJobs, ); - console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`); + logger.info("Import step complete", { created, skipped }); progressHelpers.importComplete(created, skipped); diff --git a/orchestrator/src/server/pipeline/steps/load-profile.ts b/orchestrator/src/server/pipeline/steps/load-profile.ts index 4467c68..fba6a0a 100644 --- a/orchestrator/src/server/pipeline/steps/load-profile.ts +++ b/orchestrator/src/server/pipeline/steps/load-profile.ts @@ -1,10 +1,11 @@ +import { logger } from "@infra/logger"; import { getProfile } from "../../services/profile"; export async function loadProfileStep(): Promise> { - console.log("\n📋 Loading profile..."); + logger.info("Loading profile"); return getProfile().catch((error) => { - console.warn( - "⚠️ Failed to load profile for scoring, using empty profile:", + logger.warn( + "Failed to load profile for scoring, using empty profile", error, ); return {} as Record; diff --git a/orchestrator/src/server/pipeline/steps/notify-webhook.ts b/orchestrator/src/server/pipeline/steps/notify-webhook.ts index 154d115..478ed90 100644 --- a/orchestrator/src/server/pipeline/steps/notify-webhook.ts +++ b/orchestrator/src/server/pipeline/steps/notify-webhook.ts @@ -1,3 +1,5 @@ +import { logger } from "@infra/logger"; +import { sanitizeWebhookPayload } from "@infra/sanitize"; import * as settingsRepo from "../../repositories/settings"; export async function notifyPipelineWebhookStep( @@ -22,22 +24,30 @@ export async function notifyPipelineWebhookStep( const secret = process.env.WEBHOOK_SECRET; if (secret) headers.Authorization = `Bearer ${secret}`; + const sanitizedPayload = sanitizeWebhookPayload({ + event, + sentAt: new Date().toISOString(), + pipelineRunId: payload.pipelineRunId, + jobsDiscovered: payload.jobsDiscovered, + jobsScored: payload.jobsScored, + jobsProcessed: payload.jobsProcessed, + error: payload.error, + }); + const response = await fetch(pipelineWebhookUrl, { method: "POST", headers, - body: JSON.stringify({ - event, - sentAt: new Date().toISOString(), - ...payload, - }), + body: JSON.stringify(sanitizedPayload), }); if (!response.ok) { - console.warn( - `⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`, - ); + const responseText = await response.text().catch(() => ""); + logger.warn("Pipeline webhook POST failed", { + status: response.status, + error: responseText.slice(0, 200), + }); } } catch (error) { - console.warn("⚠️ Pipeline webhook POST failed:", error); + logger.warn("Pipeline webhook POST failed", error); } } diff --git a/orchestrator/src/server/pipeline/steps/process-jobs.ts b/orchestrator/src/server/pipeline/steps/process-jobs.ts index bd57929..2b20920 100644 --- a/orchestrator/src/server/pipeline/steps/process-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/process-jobs.ts @@ -1,3 +1,4 @@ +import { logger } from "@infra/logger"; import { progressHelpers, updateProgress } from "../progress"; import type { ScoredJob } from "./types"; @@ -28,7 +29,10 @@ export async function processJobsStep(args: { if (result.success) { processedCount++; } else { - console.warn(` ⚠️ Failed to process job ${job.id}: ${result.error}`); + logger.warn("Failed to process job", { + jobId: job.id, + error: result.error, + }); } progressHelpers.jobComplete(i + 1, args.jobsToProcess.length); diff --git a/orchestrator/src/server/pipeline/steps/score-jobs.ts b/orchestrator/src/server/pipeline/steps/score-jobs.ts index 306831c..5b0d787 100644 --- a/orchestrator/src/server/pipeline/steps/score-jobs.ts +++ b/orchestrator/src/server/pipeline/steps/score-jobs.ts @@ -1,3 +1,4 @@ +import { logger } from "@infra/logger"; import type { Job } from "@shared/types"; import * as jobsRepo from "../../repositories/jobs"; import { scoreJobSuitability } from "../../services/scorer"; @@ -8,7 +9,7 @@ import type { ScoredJob } from "./types"; export async function scoreJobsStep(args: { profile: Record; }): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> { - console.log("\n🎯 Scoring jobs for suitability..."); + logger.info("Running scoring step"); const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs(); updateProgress({ @@ -73,7 +74,7 @@ export async function scoreJobsStep(args: { } progressHelpers.scoringComplete(scoredJobs.length); - console.log(`\n📊 Scored ${scoredJobs.length} jobs.`); + logger.info("Scoring step completed", { scoredJobs: scoredJobs.length }); return { unprocessedJobs, scoredJobs }; } diff --git a/orchestrator/src/server/services/llm/service.ts b/orchestrator/src/server/services/llm/service.ts index c4c2c90..8696fc5 100644 --- a/orchestrator/src/server/services/llm/service.ts +++ b/orchestrator/src/server/services/llm/service.ts @@ -1,3 +1,4 @@ +import { logger } from "@infra/logger"; import { toStringOrNull } from "@shared/utils/type-conversion"; import { buildModeCacheKey, @@ -51,7 +52,7 @@ export class LlmService { resolvedProvider === "openrouter" && toStringOrNull(process.env.OPENROUTER_API_KEY) ) { - console.warn( + logger.warn( "[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.", ); const migrated = toStringOrNull(process.env.OPENROUTER_API_KEY); @@ -180,9 +181,11 @@ export class LlmService { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { if (attempt > 0) { - console.log( - `🔄 [${jobId ?? "unknown"}] Retry attempt ${attempt}/${maxRetries}...`, - ); + logger.info("LLM retry attempt", { + jobId: jobId ?? "unknown", + attempt, + maxRetries, + }); await sleep(getRetryDelayMs(retryDelayMs, attempt)); } @@ -209,7 +212,7 @@ export class LlmService { `LLM API error: ${response.status}${detail}`, ) as LlmApiError; err.status = response.status; - err.body = errorBody; + err.body = truncate(errorBody, 600); throw err; } @@ -238,9 +241,13 @@ export class LlmService { } if (attempt < maxRetries && shouldRetryAttempt({ message, status })) { - console.warn( - `⚠️ [${jobId ?? "unknown"}] Attempt ${attempt + 1} failed (${status ?? "no-status"}): ${message}. Retrying...`, - ); + logger.warn("LLM attempt failed, retrying", { + jobId: jobId ?? "unknown", + attempt: attempt + 1, + maxRetries, + status: status ?? "no-status", + message, + }); continue; } @@ -271,9 +278,9 @@ function normalizeProvider( if (normalized === "lmstudio") return "lmstudio"; if (normalized === "ollama") return "ollama"; if (normalized && normalized !== "openrouter") { - console.warn( - `⚠️ Unknown LLM provider "${normalized}", defaulting to openrouter`, - ); + logger.warn("Unknown LLM provider, defaulting to openrouter", { + normalized, + }); } return "openrouter"; } diff --git a/orchestrator/src/server/services/llm/utils/json.ts b/orchestrator/src/server/services/llm/utils/json.ts index b892f17..5e7bb9b 100644 --- a/orchestrator/src/server/services/llm/utils/json.ts +++ b/orchestrator/src/server/services/llm/utils/json.ts @@ -1,3 +1,5 @@ +import { logger } from "@infra/logger"; + export function parseJsonContent(content: string, jobId?: string): T { let candidate = content.trim(); @@ -16,10 +18,10 @@ export function parseJsonContent(content: string, jobId?: string): T { try { return JSON.parse(candidate) as T; } catch (error) { - console.error( - `❌ [${jobId ?? "unknown"}] Failed to parse JSON:`, - candidate.substring(0, 200), - ); + logger.error("Failed to parse LLM JSON content", { + jobId: jobId ?? "unknown", + sample: candidate.substring(0, 200), + }); throw new Error( `Failed to parse JSON response: ${error instanceof Error ? error.message : "unknown"}`, ); diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts index d20ae20..67c1136 100644 --- a/orchestrator/src/server/services/manualJob.ts +++ b/orchestrator/src/server/services/manualJob.ts @@ -2,6 +2,7 @@ * Service for inferring job details from a pasted job description. */ +import { logger } from "@infra/logger"; import type { ManualJobDraft } from "@shared/types"; import { getSetting } from "../repositories/settings"; import { type JsonSchemaDefinition, LlmService } from "./llm-service"; @@ -111,7 +112,7 @@ export async function inferManualJobDetails( warning: "LLM API key not set. Fill details manually.", }; } - console.warn("Manual job inference failed:", result.error); + logger.warn("Manual job inference failed", { error: result.error }); return { job: {}, warning: "AI inference failed. Fill details manually.", diff --git a/orchestrator/src/server/services/rxresume-client.ts b/orchestrator/src/server/services/rxresume-client.ts index fed9e75..3034be2 100644 --- a/orchestrator/src/server/services/rxresume-client.ts +++ b/orchestrator/src/server/services/rxresume-client.ts @@ -7,6 +7,7 @@ import type { ResumeData } from "@shared/rxresume-schema"; type AnyObj = Record; +const MAX_ERROR_SNIPPET = 300; const TOKEN_COOKIE_NAMES = [ "accessToken", @@ -241,8 +242,10 @@ export class RxResumeClient { }); if (!res.ok) { - const text = await res.text(); - throw new Error(`Login failed: HTTP ${res.status} ${text}`); + const text = await res.text().catch(() => ""); + throw new Error( + `Login failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`, + ); } const data = (await res.json()) as AnyObj; @@ -266,7 +269,7 @@ export class RxResumeClient { if (!token || typeof token !== "string") { throw new Error( - `Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(", ")}`, + "Login succeeded but could not locate access token in response.", ); } @@ -295,8 +298,10 @@ export class RxResumeClient { }); if (!res.ok) { - const text = await res.text(); - throw new Error(`Create failed: HTTP ${res.status} ${text}`); + const text = await res.text().catch(() => ""); + throw new Error( + `Create failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`, + ); } const d = (await res.json()) as AnyObj; @@ -310,7 +315,7 @@ export class RxResumeClient { if (!id || typeof id !== "string") { throw new Error( - `Create succeeded but could not locate resume id in response. Response keys: ${Object.keys(d).join(", ")}`, + "Create succeeded but could not locate resume id in response.", ); } @@ -334,8 +339,10 @@ export class RxResumeClient { ); if (!res.ok) { - const text = await res.text(); - throw new Error(`Print failed: HTTP ${res.status} ${text}`); + const text = await res.text().catch(() => ""); + throw new Error( + `Print failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`, + ); } const d = (await res.json()) as AnyObj; @@ -348,9 +355,7 @@ export class RxResumeClient { (d?.result as AnyObj)?.href; if (!url || typeof url !== "string") { - throw new Error( - `Print succeeded but could not locate URL in response. Response: ${JSON.stringify(d)}`, - ); + throw new Error("Print succeeded but could not locate URL in response."); } return url; @@ -372,8 +377,10 @@ export class RxResumeClient { ); if (!res.ok && res.status !== 204) { - const text = await res.text(); - throw new Error(`Delete failed: HTTP ${res.status} ${text}`); + const text = await res.text().catch(() => ""); + throw new Error( + `Delete failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`, + ); } } @@ -416,8 +423,10 @@ export class RxResumeClient { }); if (!res.ok) { - const text = await res.text(); - throw new Error(`List resumes failed: HTTP ${res.status} ${text}`); + const text = await res.text().catch(() => ""); + throw new Error( + `List resumes failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`, + ); } const data = (await res.json()) as AnyObj | AnyObj[]; @@ -445,3 +454,9 @@ export class RxResumeClient { return resume; } } + +function sanitizeResponseSnippet(text: string): string { + if (!text) return ""; + const compact = text.replace(/\s+/g, " ").trim(); + return compact.slice(0, MAX_ERROR_SNIPPET); +} diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 046588d..53ef17b 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -2,6 +2,7 @@ * Service for scoring job suitability using AI. */ +import { logger } from "@infra/logger"; import type { Job } from "@shared/types"; import { getSetting } from "../repositories/settings"; import { type JsonSchemaDefinition, LlmService } from "./llm-service"; @@ -50,7 +51,7 @@ export async function scoreJobSuitability( process.env.MODEL || "google/gemini-3-flash-preview"; - const prompt = buildScoringPrompt(job, profile); + const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile)); const llm = new LlmService(); const result = await llm.callJson<{ score: number; reason: string }>({ @@ -63,11 +64,12 @@ export async function scoreJobSuitability( if (!result.success) { if (result.error.toLowerCase().includes("api key")) { - console.warn("⚠️ LLM API key not set, using mock scoring"); + logger.warn("LLM API key not set, using mock scoring", { jobId: job.id }); } - console.error( - `❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`, - ); + logger.error("Scoring failed, using mock scoring", { + jobId: job.id, + error: result.error, + }); return mockScore(job); } @@ -75,9 +77,9 @@ export async function scoreJobSuitability( // Validate we got a reasonable response if (typeof score !== "number" || Number.isNaN(score)) { - console.error( - `❌ [Job ${job.id}] Invalid score in response, using mock scoring`, - ); + logger.error("Invalid score in AI response, using mock scoring", { + jobId: job.id, + }); return mockScore(job); } @@ -173,21 +175,19 @@ export function parseJsonFromContent( const reason = reasonMatch ? reasonMatch[1].trim().replace(controlCharsRegex, "") : "Score extracted from malformed response"; - console.log( - `⚠️ [Job ${jobId || "unknown"}] Parsed score via regex fallback: ${score}`, - ); + logger.warn("Parsed score via regex fallback", { + jobId: jobId || "unknown", + score, + }); return { score, reason }; } // Log the failure with full content for debugging - console.error( - `❌ [Job ${jobId || "unknown"}] Failed to parse AI response. Raw content (first 500 chars):`, - originalContent.substring(0, 500), - ); - console.error( - ` Sanitized content (first 500 chars):`, - sanitized.substring(0, 500), - ); + logger.error("Failed to parse AI response", { + jobId: jobId || "unknown", + rawSample: originalContent.substring(0, 500), + sanitizedSample: sanitized.substring(0, 500), + }); throw new Error("Unable to parse JSON from model response"); } @@ -228,6 +228,38 @@ EXAMPLE VALID RESPONSE: {"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`; } +function sanitizeProfileForPrompt( + profile: Record, +): Record { + const p = profile as { + basics?: Record; + sections?: { + skills?: unknown; + experience?: { items?: unknown[] }; + projects?: { items?: unknown[] }; + education?: { items?: unknown[] }; + }; + }; + + const experienceItems = Array.isArray(p.sections?.experience?.items) + ? p.sections?.experience?.items.slice(0, 5) + : []; + const projectItems = Array.isArray(p.sections?.projects?.items) + ? p.sections?.projects?.items.slice(0, 6) + : []; + + return { + basics: { + label: p.basics?.label, + summary: p.basics?.summary, + }, + skills: p.sections?.skills ?? null, + experience: experienceItems, + projects: projectItems, + education: p.sections?.education?.items ?? [], + }; +} + function mockScore(job: Job): SuitabilityResult { // Simple keyword-based scoring as fallback const jd = (job.jobDescription || "").toLowerCase(); diff --git a/orchestrator/src/server/services/summary.ts b/orchestrator/src/server/services/summary.ts index a96e434..ee510e6 100644 --- a/orchestrator/src/server/services/summary.ts +++ b/orchestrator/src/server/services/summary.ts @@ -2,6 +2,7 @@ * Service for generating tailored resume content (Summary, Headline, Skills). */ +import { logger } from "@infra/logger"; import type { ResumeProfile } from "@shared/types"; import { getSetting } from "../repositories/settings"; import { type JsonSchemaDefinition, LlmService } from "./llm-service"; @@ -88,7 +89,7 @@ export async function generateTailoring( const context = `provider=${llm.getProvider()} baseUrl=${llm.getBaseUrl()}`; if (result.error.toLowerCase().includes("api key")) { const message = `LLM API key not set, cannot generate tailoring. (${context})`; - console.warn(`⚠️ ${message}`); + logger.warn(message); return { success: false, error: message }; } return { @@ -101,7 +102,7 @@ export async function generateTailoring( // Basic validation if (!summary || !headline || !Array.isArray(skills)) { - console.warn("⚠️ AI response missing required fields:", result.data); + logger.warn("AI response missing required tailoring fields", result.data); } return { diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json index e03d08b..52f3e7c 100644 --- a/orchestrator/tsconfig.json +++ b/orchestrator/tsconfig.json @@ -14,6 +14,7 @@ "paths": { "@/*": ["src/*"], "@server/*": ["src/server/*"], + "@infra/*": ["src/server/infra/*"], "@client/*": ["src/client/*"], "@shared/*": ["../shared/src/*"] } diff --git a/orchestrator/vite.config.ts b/orchestrator/vite.config.ts index 3950c81..99bcab3 100644 --- a/orchestrator/vite.config.ts +++ b/orchestrator/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), "@client": path.resolve(__dirname, "./src/client"), "@server": path.resolve(__dirname, "./src/server"), + "@infra": path.resolve(__dirname, "./src/server/infra"), "@shared": path.resolve(__dirname, "../shared/src"), }, }, diff --git a/package.json b/package.json index b7fe323..5614f4d 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "check:types:shared": "npm --workspace shared run check:types", "check:types": "npm --workspace shared run check:types && npm --workspace orchestrator run check:types", "check:types:ukvisajobs": "npm --workspace ukvisajobs-extractor run check:types", - "check:all": "npx biome ci .", - "format:all": "npx biome format . --write", + "check:all": "./orchestrator/node_modules/.bin/biome ci .", + "format:all": "./orchestrator/node_modules/.bin/biome format . --write", "check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types" }, "devDependencies": { diff --git a/shared/src/types.ts b/shared/src/types.ts index 8999d37..0b37e6c 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -309,12 +309,28 @@ export interface PipelineRun { } // API Response types -export interface ApiResponse { - success: boolean; - data?: T; - error?: string; +export interface ApiMeta { + requestId: string; } +export interface ApiErrorPayload { + code: string; + message: string; + details?: unknown; +} + +export type ApiResponse = + | { + ok: true; + data: T; + meta?: ApiMeta; + } + | { + ok: false; + error: ApiErrorPayload; + meta: ApiMeta; + }; + export interface JobsListResponse { jobs: Job[]; total: number;