diff --git a/src/lib/agent/run.ts b/src/lib/agent/run.ts index 54424b7d..ed890d6b 100644 --- a/src/lib/agent/run.ts +++ b/src/lib/agent/run.ts @@ -9,9 +9,9 @@ import { isAiConfigured } from "@/lib/ai/config"; import { AiError } from "@/lib/ai/errors"; import { loadTaskOptions } from "@/lib/ai/task-options"; import { scorePosting } from "@/lib/ai/tasks"; -import { searchPostings } from "@/lib/job-search"; -import { credentialsFor, loadJobSearchConfig, requestContext } from "@/lib/job-search/config"; +import { credentialsFor, loadJobSearchConfig } from "@/lib/job-search/config"; import { JobSearchError } from "@/lib/job-search/errors"; +import { searchWithFallback } from "@/lib/job-search/find"; import { postingKey, postingText, type FoundPosting, type SearchQuery } from "@/lib/job-search/search"; import { startJob, type JobContext } from "@/lib/jobs/runner"; import { logServerError } from "@/lib/log"; @@ -113,15 +113,12 @@ export async function runAgentScan({ manual }: { manual: boolean }, context: Job await context.step("Searching for new postings"); const config = await loadJobSearchConfig(); - const source = config.source!; - let postings: FoundPosting[]; - try { - // The first page only: the agent never asks for a second. - postings = (await searchPostings(source, credentialsFor(config, source), settings.agentQuery, null, requestContext(source))).postings; - } catch (error) { - if (error instanceof JobSearchError) return finish(stopped(error.message)); - throw error; - } + // The first page only: the agent never asks for a second. Falls back across every source with a key saved + // (E14-9) if the chosen one can't answer, except the Bundesagentur für Arbeit, which the agent never searches + // on its own (#223) whether it's chosen or not. + const answer = await searchWithFallback(config, config.source!, settings.agentQuery, null, { exclude: ["ARBEITSAGENTUR"] }); + if (!answer.ok) return finish(stopped(answer.message)); + const postings: FoundPosting[] = answer.postings; const fresh = await newPostings(postings); const counts = { found: postings.length, fresh: fresh.length, skipped: postings.length - fresh.length, drafted: 0 }; diff --git a/src/lib/job-search/find.ts b/src/lib/job-search/find.ts index bc2b89ce..a5c8128d 100644 --- a/src/lib/job-search/find.ts +++ b/src/lib/job-search/find.ts @@ -1,33 +1,97 @@ import "server-only"; -import { credentialsFor, loadJobSearchConfig, requestContext } from "./config"; +import { credentialsFor, loadJobSearchConfig, requestContext, type JobSearchConfig } from "./config"; import { JobSearchError } from "./errors"; import { postingDetails, searchPostings } from "./index"; -import type { FoundPosting, JobSource, SearchPage, SearchProblem, SearchQuery } from "./search"; +import { JOB_SOURCES, type FoundPosting, type JobSource, type SearchPage, type SearchProblem, type SearchQuery } from "./search"; +import { SOURCE_INFO } from "./sources"; // Asking a job source from Find jobs (E14-6): a page of postings, or what // went wrong in words for the screen. Kept searches are in // src/lib/posting-searches.ts. +export type SourceAnswer = ({ ok: true; source: JobSource } & SearchPage) | SearchProblem; + /** - * One page for `query` from `source`, the one chosen in Settings unless - * given (a kept search goes on with the source it ran on). + * The order to try sources in for a fresh search (E14-9): the one chosen in + * Settings first, then the rest in the order Settings lists them. */ -export async function askSource( +function fallbackOrder(primary: JobSource): JobSource[] { + return [primary, ...JOB_SOURCES.filter((source) => source !== primary)]; +} + +/** + * One page for `query`, trying every source with a key saved — starting + * from `primary` — until one answers (E14-9): one source's exhausted quota, + * an outage, or a country it doesn't cover no longer stops a search, it + * just moves on to the next. A source with no key saved (or, for the + * Bundesagentur für Arbeit, not yet switched on) is skipped without being + * asked at all. Shared by askSource below and the job agent's search step + * (src/lib/agent/run.ts), so both get the same resilience; the agent + * excludes the Bundesagentur für Arbeit itself (it never searches it, #223). + */ +export async function searchWithFallback( + config: JobSearchConfig, + primary: JobSource, query: SearchQuery, - { source, next = null }: { source?: JobSource; next?: string | null } = {}, -): Promise<({ ok: true; source: JobSource } & SearchPage) | SearchProblem> { + next: string | null = null, + { exclude = [] }: { exclude?: readonly JobSource[] } = {}, +): Promise { + // A source with no key saved (or, for the Bundesagentur für Arbeit, not yet switched on) was never asked, so it's + // not a failed attempt — it's skipped silently, the way it would be if it didn't exist. The one exception is the + // primary source itself: if nothing else is configured either, its own "add a key" message is the most useful + // thing to say, same as before there was anything to fall back to. + let primaryNotConfigured: SearchProblem | null = null; + const attempts: { source: JobSource; problem: SearchProblem }[] = []; + for (const candidate of fallbackOrder(primary)) { + if (exclude.includes(candidate)) continue; + let credentials; + try { + credentials = credentialsFor(config, candidate); + } catch (error) { + if (!(error instanceof JobSearchError)) throw error; + if (candidate === primary) primaryNotConfigured = { ok: false, problem: error.problem, message: error.message }; + continue; + } + try { + const page = await searchPostings(candidate, credentials, query, next, requestContext(candidate)); + return { ok: true, source: candidate, ...page }; + } catch (error) { + if (!(error instanceof JobSearchError)) throw error; + attempts.push({ source: candidate, problem: { ok: false, problem: error.problem, message: error.message } }); + } + } + if (attempts.length === 0) return primaryNotConfigured ?? { ok: false, problem: "off", message: "Choose where to search in Settings → Job search first." }; + if (attempts.length === 1) return attempts[0].problem; + return { + ok: false, + problem: attempts[0].problem.problem, + message: `None of your job sources could answer. ${attempts.map((a) => `${SOURCE_INFO[a.source].name}: ${a.problem.message}`).join(" ")}`, + }; +} + +/** + * One page for `query` from `source`, the one chosen in Settings unless + * given (a kept search goes on with the exact source it ran on — its `next` + * cursor only means something there, so a fresh search's fallback across + * sources doesn't apply to "Show more"). + */ +export async function askSource(query: SearchQuery, { source, next = null }: { source?: JobSource; next?: string | null } = {}): Promise { const config = await loadJobSearchConfig(); - const from = source ?? config.source; - if (!from) return { ok: false, problem: "off", message: "Choose where to search in Settings → Job search first." }; if (!query.country) return { ok: false, problem: "no-country", message: "Choose a country to search in." }; - try { - const page = await searchPostings(from, credentialsFor(config, from), query, next, requestContext(from)); - return { ok: true, source: from, ...page }; - } catch (error) { - if (error instanceof JobSearchError) return { ok: false, problem: error.problem, message: error.message }; - throw error; + + if (source) { + try { + const page = await searchPostings(source, credentialsFor(config, source), query, next, requestContext(source)); + return { ok: true, source, ...page }; + } catch (error) { + if (error instanceof JobSearchError) return { ok: false, problem: error.problem, message: error.message }; + throw error; + } } + + if (!config.source) return { ok: false, problem: "off", message: "Choose where to search in Settings → Job search first." }; + return searchWithFallback(config, config.source, query, next); } /** diff --git a/src/lib/posting-searches.db.test.ts b/src/lib/posting-searches.db.test.ts index 448e4fd0..fecb7a03 100644 --- a/src/lib/posting-searches.db.test.ts +++ b/src/lib/posting-searches.db.test.ts @@ -23,6 +23,8 @@ const prisma = () => db.client!; const query = { ...EMPTY_QUERY, keywords: "product designer", place: "Hamburg", country: "de" }; const useJSearch = () => saveSettings({ jobSource: "JSEARCH", jsearchKeyEnc: encryptSecret(JOB_SOURCE_KEY, "ak_test") }); +const addAdzunaKey = () => saveSettings({ adzunaAppId: "app", adzunaKeyEnc: encryptSecret(JOB_SOURCE_KEY, "adz_test") }); +const adzunaAnswer = (results: unknown[] = []) => json({ results, count: results.length }); beforeEach(async () => { await resetDatabase(prisma()); @@ -70,6 +72,49 @@ describe("runSearch", () => { }); }); +describe("falling back across sources (E14-9)", () => { + it("moves on to the next source with a key saved when the chosen one is exhausted", async () => { + await useJSearch(); + await addAdzunaKey(); + const requests = stubFetch((request) => + request.url.hostname === "api.openwebninja.com" ? json({}, { status: 429 }) : adzunaAnswer([{ id: "1", title: "Designer" }]), + ); + const result = await runSearch(query); + expect(result).toEqual({ ok: true, id: expect.any(String) }); + const kept = await prisma().postingSearch.findUniqueOrThrow({ where: { id: result.ok ? result.id : "" } }); + expect(requests.filter((r) => r.url.hostname === "api.openwebninja.com")).toHaveLength(1); + expect(requests.filter((r) => r.url.hostname === "api.adzuna.com")).toHaveLength(1); + expect(kept.source).toBe("ADZUNA"); + }); + + it("says so, naming each one, when every configured source fails", async () => { + await useJSearch(); + await addAdzunaKey(); + stubFetch((request) => (request.url.hostname === "api.openwebninja.com" ? json({}, { status: 429 }) : json({}, { status: 401 }))); + const result = await runSearch(query); + expect(result).toMatchObject({ + ok: false, + problem: "quota", + message: "None of your job sources could answer. JSearch: You've used up your JSearch searches for this month. Try again then, or move to a bigger plan. Adzuna: Adzuna didn't accept your key. Check it in Settings → Job search.", + }); + }); + + it("stops at the one source saved, same as before there was anything to fall back to", async () => { + await saveSettings({ jobSource: "JSEARCH" }); + expect(await runSearch(query)).toEqual({ ok: false, problem: "no-key", message: "Add your JSearch key in Settings → Job search first." }); + }); + + it("doesn't fall back for Show more: it stays on the search's own source", async () => { + await useJSearch(); + await addAdzunaKey(); + stubFetch(() => json(jsearchV2Page)); + const run = await runSearch(query); + const id = run.ok ? run.id : ""; + stubFetch((request) => (request.url.hostname === "api.openwebninja.com" ? json({}, { status: 429 }) : adzunaAnswer([]))); + expect(await showMoreFor(id)).toMatchObject({ ok: false, problem: "quota" }); + }); +}); + describe("loadSearch", () => { it("opens a kept search without asking the source, with where each posting stands now", async () => { await useJSearch();