/** * Background job registry — in-memory hot path, SQLite-backed shadow. * * Some ingest paths are slow — `ingest_repo` against a 2000-file tree * can take a minute; `ingest_url` with a deep crawl can take longer. * Synchronous handlers tie up the MCP transport for that whole time * and surface to the caller as 'is it stuck?' silence. The job * registry lets a handler return `{ jobId, queued: true }` immediately * and run the actual work in the background; callers poll * `kb_job_status({ jobId })` for progress. * * Concurrency control: jobs submitted via `enqueue()` respect a * process-wide cap (MAX_CONCURRENT). Excess jobs sit in the registry * with status='queued' until a slot opens. Cortex runs single-tenant * per Fly machine so a process-wide cap IS the per-tenant cap. Without * this, two parallel ingest_repo calls OOM the box (reproduced today * during the first cortex codebase ingest experiment). * * Persistence: every status transition is mirrored to a SQLite * `cache_jobs` table via `@onenomad/przm-cortex-cache-sqlite`. The * in-memory map remains the hot read path; restarting the process or * a stale-poll race that lands on a process the registry hasn't seen * yet falls back to the persistent store. Without this a Fly machine * recycle (or any dev iteration) stranded the Dashboard's Jobs view * with empty 'Recent' lists. * * Caller pattern (preferred — concurrency-aware): * const job = jobs.create({ kind: 'ingest_repo' }); * jobs.enqueue(job.id, () => runWork()); * return { jobId: job.id, queued: true }; * * Legacy pattern (still supported, ignores concurrency cap): * const job = jobs.create({ kind: 'ingest_repo' }); * void runWork() * .then((result) => jobs.complete(job.id, result)) * .catch((err) => jobs.fail(job.id, err)); */ import type { JobsListOptions, JobsStorage } from "@onenomad/przm-cortex-cache-sqlite"; export type JobStatus = "queued" | "running" | "completed" | "failed"; export interface JobRecord { id: string; /** Tool name that created the job (ingest_repo, ingest_url, etc.). */ kind: string; status: JobStatus; createdAtMs: number; startedAtMs: number | null; finishedAtMs: number | null; /** * Free-form progress payload the handler can update mid-flight. * Convention: `{ totalUnits?: number, doneUnits?: number, message?: string }` * but the registry doesn't enforce shape — clients render what's * present and skip what isn't. */ progress: Record; /** Final result on completed jobs. Mirrors the synchronous return. */ result: unknown | null; /** Error message on failed jobs. */ error: string | null; /** * Workspace slug this job was created in. Empty string when the * session was in no-workspace mode. The persistent shadow surfaces * this so the Dashboard Jobs page can scope listings per workspace. */ workspace: string; } declare class JobRegistry { private readonly jobs; private readonly waiting; private active; private readonly maxConcurrent; private storage; /** Default workspace stamped on jobs when create() isn't passed one. */ private defaultWorkspace; /** * Wire a persistent shadow. Called once at server boot after the * SQLite cache opens — tests skip this so they exercise the * in-memory path alone. */ setStorage(storage: JobsStorage | null): void; /** * Set the workspace slug auto-stamped on every job created without * an explicit `workspace`. Boot-time wiring uses this so the * dashboard's per-workspace listing works without every caller * having to thread the slug through. */ setDefaultWorkspace(slug: string): void; create(opts: { kind: string; workspace?: string; }): JobRecord; start(jobId: string): void; /** * Submit a job to the concurrency-capped runner. Use this instead of * calling work() directly — it respects MAX_CONCURRENT_JOBS so the * process doesn't OOM under parallel submission. Jobs over the cap * sit at status='queued' until a slot opens; the registry transitions * them to 'running' when the worker actually starts the work. */ enqueue(jobId: string, work: () => Promise): void; /** * How many slots are currently busy / waiting. Useful for the * dashboard / kb_job_status surface; not part of the MCP tool API. */ utilization(): { active: number; waiting: number; max: number; }; private runOne; progress(jobId: string, patch: Record): void; complete(jobId: string, result: unknown): void; fail(jobId: string, err: unknown): void; get(jobId: string): JobRecord | undefined; /** * Listing surface used by the Dashboard. Reads the persistent store * directly so both in-flight and historical rows show up; the * in-memory map is a write-through cache so it has nothing the * shadow doesn't already carry. */ list(opts?: JobsListOptions): JobRecord[]; private persist; /** * Drop completed / failed jobs older than RETENTION_MS. Called * lazily on every create() — no separate timer to leak. */ private gc; /** Test-only: dump all jobs. Not part of the MCP surface. */ _all(): readonly JobRecord[]; /** Test-only: clear all jobs (in-memory only — storage cleared via wipe). */ _reset(): void; } /** * Singleton — handlers + the kb_job_status tool both reach for it. * Scoping to a singleton matches the rest of cortex's process model * (one MCP server per workspace). */ export declare const jobs: JobRegistry; export {}; //# sourceMappingURL=jobs.d.ts.map