Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions src/lib/agent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };
Expand Down
94 changes: 79 additions & 15 deletions src/lib/job-search/find.ts
Original file line number Diff line number Diff line change
@@ -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<SourceAnswer> {
// 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<SourceAnswer> {
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);
}

/**
Expand Down
45 changes: 45 additions & 0 deletions src/lib/posting-searches.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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();
Expand Down