Workflow-App/Workflow/app/api/cipc/search/route.ts
sjcoetzeesj-coder 0717400bb0 fix(cipc): say plainly when search runs where it cannot
CIPC/BizPortal search drives a real Chrome session through agent-browser
to log into BizPortal and scrape the result pane. That toolchain exists
only on the Windows dev machine; the Coolify image is a Linux container
with no browser tooling (the Dockerfile header already says so).

Nothing detected that up front, so a search on the deployed URL fell
through to the legacy spawnSync fallback, which builds a Windows-style
interpreter path even on Linux (.venv/Scripts/ plus python). The shell
answered 'not found' with exit 127, extractError() found no marker line
in stdout and returned 'Unknown error', and the UI rendered that over
the raw shell message - a dead end with no action in it.

Now the route checks for the interpreter it needs before doing any
work, and returns 503 with a sentence naming the real cause and where
to run the search instead. Checked per request so it cannot go stale.

Verified: typecheck clean; a real enterprise search of K2022808158 on
the local dev server still returns 200 in 87s with 3 directors, so the
guard does not touch the working Windows path.
2026-10-20 09:00:41 +02:00

798 lines
29 KiB
TypeScript

// app/api/cipc/search/route.ts
//
// POST /api/cipc/search
// Body: { kind: "enterprise_no" | "director_id_no", query: string }
//
// For Enterprise No. search the .py expects a *company name* (it looks up the
// K-number from PocketBase). To honour SJ's intent ("search BizPortal directly,
// PB shouldn't matter"), we resolve K-number inputs here at the API edge:
//
// K-number pattern (e.g. K2018435959, M2004008102, B2020000123,
// or unprefixed 2022808158 / 2022/808158/08)
// → look up enterprise_name via PB `enterprise_no` field
// → pass the name to the script
// anything else → pass straight through as a company name
//
// BizPortal itself is still hit with the K-number (the .py resolves name→K
// then searches BizPortal), so PB is just a convenience index — not a gate.
// If PB doesn't know the K, we still hand the raw query to the script (which
// will then try to look it up itself and likely fail with a clear "no PB record
// matched" error).
//
// Director ID No. searches (13-digit SA ID) go straight to the .py as-is.
//
// ENRICHMENT: after the script returns, we extract every Enterprise Number
// mentioned in the captured markdown and look each one up in
// `ysterbaard_companies` (PB). The enrichment is returned in the response as
// `collection_matches: { [ent_no]: { in_collection, pb_id?, pb_name?, pb_status? } }`
// so the UI can render an "In your collection" pill per row and offer "+ Add"
// for missing ones.
import { NextRequest, NextResponse } from "next/server";
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export const dynamic = "force-dynamic";
export const maxDuration = 180;
const SCRIPTS_DIR = path.join(process.cwd(), "scripts");
// ===================================================================
// WRAPPER SERVICE AUTOSTART
// ===================================================================
// The fast search path is the long-lived wrapper service
// (scripts/_cipc_service.js) on 127.0.0.1:6002, which holds a warm BizPortal
// login. When it is down the route silently degrades to the legacy fallback
// — a cold login on every search, ~70-90s, previously with no timeout at
// all. On 2026-10-09 we found the service had not run since 18 September,
// so CIPC search had been crawling or hanging for three weeks with nothing
// to show why.
//
// So we start it ourselves, lazily, the first time a search comes through.
//
// This lives in the route handler rather than Next's `instrumentation.ts`
// on purpose: the instrumentation bundle is built for the edge runtime,
// where `node:child_process` / `node:net` cannot be resolved. Enabling it
// fails the build and 500s the entire app. Route handlers are compiled for
// the Node runtime, so the builtins resolve correctly.
//
// Probes port 6002 on every search and respawns the wrapper (rate-limited)
// if it is down. The probe means the route self-heals: if the wrapper dies
// (or the agent-browser session it owns hangs) the next search brings it
// back instead of silently timing out at 165 s and 500-ing forever. Hot
// reloads in `next dev` are also fine — once a wrapper is up, the probe
// returns true and we no-op.
// Set CIPC_AUTOSTART=0 to disable.
const WRAPPER_PORT = 6002;
// Cooldown between respawns when the port is closed. A single bad spawn
// (e.g. the wrapper crashed on startup) must not trigger a respawn loop
// that pegs the CPU.
let lastWrapperSpawnMs = 0;
const WRAPPER_RESPAWN_COOLDOWN_MS = 30_000;
function portIsOpen(port: number, ms: number): Promise<boolean> {
// Local require rather than the module-level `net` const declared further
// down, so there is no ordering dependency between the two.
const netMod = require("node:net") as typeof import("node:net");
return new Promise((resolve) => {
const socket = netMod.createConnection({ host: "127.0.0.1", port });
const finish = (up: boolean) => {
socket.destroy();
resolve(up);
};
socket.setTimeout(ms);
socket.once("connect", () => finish(true));
socket.once("error", () => finish(false));
socket.once("timeout", () => finish(false));
});
}
async function ensureWrapperService(): Promise<void> {
if (process.env.CIPC_AUTOSTART === "0") return;
try {
// Already running? Leave it alone.
if (await portIsOpen(WRAPPER_PORT, 700)) return;
// Port is closed — respawn, but rate-limited so a startup failure
// can't turn into a tight respawn loop.
const now = Date.now();
if (now - lastWrapperSpawnMs < WRAPPER_RESPAWN_COOLDOWN_MS) return;
lastWrapperSpawnMs = now;
const scriptPath = path.join(SCRIPTS_DIR, "_cipc_service.js");
if (!fs.existsSync(scriptPath)) {
console.warn("[cipc] _cipc_service.js not found — skipping autostart");
return;
}
// Detached, but output still captured — the missing log is what made
// the three-week outage invisible in the first place.
const logFd = fs.openSync(path.join(process.cwd(), "_cipc_service.log"), "a");
const child = spawn(process.execPath, [scriptPath], {
detached: true,
stdio: ["ignore", logFd, logFd],
windowsHide: true,
cwd: process.cwd(),
});
child.unref();
console.log(`[cipc] wrapper service autostarted on ${WRAPPER_PORT} — pid ${child.pid}`);
} catch (err) {
// Never block the search; the legacy fallback still works.
console.warn("[cipc] wrapper autostart failed:", err);
}
}
const SCRIPTS: Record<string, string> = {
enterprise_no: "Bizportal Enterprise No. Search.py",
director_id_no: "Bizportal Directopr id no search.py",
};
// Recognise any of:
// K/M/B + 10 digits (modern prefix)
// 10-12 digits on their own (some CIPC numbers are 8-digit numeric + suffix)
// "2022/808158/08" — the legacy close-corporation / old-style number
const ENTERPRISE_NO_PATTERNS: RegExp[] = [
/^[KMB]\d{10,12}$/i,
/^\d{10,12}$/,
/^\d{4}\/\d{6,7}\/\d{2}$/,
];
function isEnterpriseNo(s: string): boolean {
return ENTERPRISE_NO_PATTERNS.some((re) => re.test(s.trim()));
}
/**
* Generate every sensible text form of an Enterprise Number we might find in
* PocketBase. PB usually stores the canonical K-prefixed form, but legacy
* records can be slash-formatted (2022/808158/08) or digit-only. Used by the
* collection_matches enrichment to broaden the PB filter and avoid misses.
*/
function normalizeEnterpriseNoForms(raw: string): string[] {
const t = raw.trim().toUpperCase();
const digits = t.replace(/^([KMB])|[\s/]/g, "");
const out = new Set<string>();
if (/^[KMB]/.test(t)) out.add(t);
out.add(digits);
if (digits.length >= 8) out.add(`K${digits}`);
const slashMatch = t.match(/^(\d{4})\/(\d{6,7})(?:\/\d{2})?$/);
if (slashMatch) {
out.add(`K${slashMatch[1]}${slashMatch[2]}`);
}
out.add(t);
return Array.from(out);
}
/** Read PB_TOKEN from Workflow/.env.local. */
function readPbToken(): string {
const fs = require("node:fs") as typeof import("node:fs");
const envPath = path.join(process.cwd(), ".env.local");
try {
const raw = fs.readFileSync(envPath, "utf-8");
for (const line of raw.split(/\r?\n/)) {
if (line.startsWith("PB_TOKEN=")) {
return line.slice("PB_TOKEN=".length).trim();
}
}
} catch {}
return "";
}
/** Hit PocketBase to resolve a K-number to a company name. Returns null on miss. */
async function resolveEnterpriseNoToName(kNo: string): Promise<string | null> {
const token = readPbToken();
if (!token) return null;
const unique = normalizeEnterpriseNoForms(kNo);
for (const candidate of unique) {
const filter = `enterprise_no="${candidate.replace(/"/g, '\\"')}"`;
const url = `https://pb.agent.apphub.co.za/api/collections/ysterbaard_companies/records?perPage=1&filter=${encodeURIComponent(filter)}`;
try {
const r = await fetch(url, {
headers: { Authorization: token },
signal: AbortSignal.timeout(8000),
});
if (!r.ok) continue;
const j = await r.json();
const item = j?.items?.[0];
if (item?.enterprise_name) return item.enterprise_name;
} catch {
// ignore — fall through to next candidate or null
}
}
return null;
}
// ===================================================================
// WRAPPER-SERVICE CALL (Architecture A6)
// ===================================================================
//
// If scripts/_cipc_service.js is running on 127.0.0.1:6002, prefer it.
// The wrapper owns the agent-browser session and watches it via a 30 s
// tick; calls are serialised through a queue so login + search can't
// race. When the wrapper is offline (connection refused) we transparently
// fall back to the legacy `spawnSync` path below — full backwards compat.
// ===================================================================
const net = require("node:net") as typeof import("node:net");
/** How long the legacy cold-login path may run before we give up. */
const LEGACY_SPAWN_TIMEOUT_MS = 180_000;
interface WrapperResponse {
ok: boolean;
/** Populated on success; full .py stdout. */
stdout?: string;
/** Populated on non-zero exit. */
exitCode?: number;
duration_ms?: number;
warmed_up?: boolean;
stderr?: string;
error?: string;
}
function callWrapper(
kind: string,
query: string,
timeoutMs: number
): Promise<WrapperResponse> {
return new Promise((resolve) => {
let resolved = false;
let buf = "";
let socket: ReturnType<typeof net.createConnection> | null = null;
const start = Date.now();
function done(res: WrapperResponse) {
if (resolved) return;
resolved = true;
try { socket?.end(); } catch {}
resolve(res);
}
const timer = setTimeout(() => {
done({ ok: false, error: `wrapper timeout (${timeoutMs}ms)`, duration_ms: Date.now() - start });
}, timeoutMs);
try {
socket = net.createConnection({ host: "127.0.0.1", port: 6002 });
} catch (e: any) {
clearTimeout(timer);
done({ ok: false, error: `wrapper connect failed: ${e?.message || String(e)}` });
return;
}
socket.setTimeout(timeoutMs);
socket.on("connect", () => {
const line =
JSON.stringify({ kind, query }) + "\n";
socket!.write(line);
});
socket.on("data", (chunk) => {
buf += chunk.toString("utf-8");
const idx = buf.indexOf("\n");
if (idx >= 0) {
const line = buf.slice(0, idx);
clearTimeout(timer);
try {
done({ ...JSON.parse(line), duration_ms: Date.now() - start });
} catch (e: any) {
done({ ok: false, error: `wrapper response invalid json: ${e?.message || String(e)}`, duration_ms: Date.now() - start });
}
}
});
socket.on("error", (e: any) => {
clearTimeout(timer);
done({ ok: false, error: `wrapper socket error: ${e?.message || String(e)}` });
});
socket.on("close", () => {
clearTimeout(timer);
if (!resolved) {
done({ ok: false, error: "wrapper closed without response" });
}
});
});
}
interface CollectionMatch {
in_collection: boolean;
pb_id?: string;
pb_name?: string;
pb_status?: string;
}
/**
* For every Enterprise Number mentioned in the script's output, ask PocketBase
* whether a record already exists. Returns a map keyed by the canonical
* (K-prefixed) form the script emitted — that's what the UI uses to match
* rows in the results table. Per-entNo normal forms are unioned into a single
* OR filter so PB is hit at most once per request regardless of result count.
*/
async function collectCollectionMatches(
entNos: string[],
token: string,
): Promise<Record<string, CollectionMatch>> {
const out: Record<string, CollectionMatch> = {};
if (entNos.length === 0 || !token) return out;
// Build a deduped pool of every possible PB-side text form.
const candidates = new Set<string>();
const canonicalByForm = new Map<string, string>(); // form -> original ent_no we want to key by
for (const e of entNos) {
const forms = normalizeEnterpriseNoForms(e);
for (const f of forms) canonicalByForm.set(f, e.toUpperCase());
for (const f of forms) candidates.add(f);
}
if (candidates.size === 0) return out;
const filter = Array.from(candidates)
.map((c) => `enterprise_no="${c.replace(/"/g, '\\"')}"`)
.join(" || ");
const url =
`https://pb.agent.apphub.co.za/api/collections/ysterbaard_companies/records` +
`?perPage=500&filter=${encodeURIComponent(filter)}`;
try {
const r = await fetch(url, {
headers: { Authorization: token },
signal: AbortSignal.timeout(8000),
});
if (!r.ok) return out;
const j = await r.json();
const items: any[] = j?.items ?? [];
for (const item of items) {
const originalKey = canonicalByForm.get(item.enterprise_no);
if (!originalKey) continue;
out[originalKey] = {
in_collection: true,
pb_id: item.id,
pb_name: item.enterprise_name || "",
pb_status: item.enterprise_status || "",
};
}
// Mark every requested ent_no as not-in-collection if it isn't in `out`.
for (const e of entNos) {
const k = e.toUpperCase();
if (!out[k]) out[k] = { in_collection: false };
}
} catch {
// PB unreachable — leave entNos un-enriched; UI will show "unknown"
for (const e of entNos) {
out[e.toUpperCase()] = { in_collection: false };
}
}
return out;
}
/**
* Pull every Enterprise Number mentioned in the .py's markdown output so we
* can ask PB about each one.
*
* For "director_id_no" — each data row's first cell is an Enterprise Number.
* For "enterprise_no" — the canonical K-number lives in the title in parens
* ("## Company: ... (K2018435959)"). The body sections also repeat it but
* we only need it once.
*/
function extractEnterpriseNos(
md: string,
kind: "enterprise_no" | "director_id_no",
): string[] {
if (kind === "director_id_no") {
const seen = new Set<string>();
for (const m of md.matchAll(/^\|\s*([KMB]\d{10,12})\s*\|/gim)) {
seen.add(m[1].toUpperCase());
}
return Array.from(seen);
}
const titleMatch = md.match(/\(([KMB]\d{10,12})\)/);
return titleMatch ? [titleMatch[1].toUpperCase()] : [];
}
// ===================================================================
// CIPC FIELD EXTRACTION (Enterprise No. search only)
// ===================================================================
interface CipcFields {
/** Flat key/value pairs from the "### ENTERPRISE DETAILS" section. Keys
* are the BizPortal labels as printed ("Enterprise Type", "Status",
* "Registration Date", etc.). The UI maps these onto PB fields. */
kv: Record<string, string>;
/** Director rows from the "### DIRECTOR DETAILS" section table. */
directors: Array<{ name: string; id: string }>;
}
/**
* Extract the structured data we want from the .py's Enterprise No. markdown.
*
* The `.py` output for Enterprise No. has these sections (frozen script):
* ### COMPANY DETAILS — KV lines we map onto PB fields.
* ### DIRECTOR DETAILS — table with columns
* [ID Number | Name(s) | Surname | Type | Status].
* ### FILED ANNUAL RETURNS — ignore (no PB equivalent field).
* ### OUTSTANDING ANNUAL RETURNS — ignore.
* ### OTHER DETAILS — partial overlap (SARS tax number etc.),
* not surfaced in the diff yet — the diff
* only handles fields CIPC labels as
* "CIPC Physical address" / "CIPC Postal
* address" and the canonical KV entries.
*/
function extractCipcFields(md: string): CipcFields {
const out: CipcFields = { kv: {}, directors: [] };
// COMPANY DETAILS — KV lines like "- Enterprise Type: Private Company".
// CIPC labels use colons with potentially long keys including "CIPC".
const ent = md.match(
/### COMPANY DETAILS\s*\n([\s\S]*?)(?=\n### |\n*$)/i
);
if (ent) {
for (const line of ent[1].split(/\r?\n/)) {
const m = line.match(/^\s*-\s*([^:]+?):\s*(.+?)\s*$/);
if (m) out.kv[m[1].trim()] = m[2].trim();
}
}
// DIRECTOR DETAILS — 5-column table; collapse Name + Surname into the
// single "name" field so the UI gets a clean "Name (ID)" display.
const dir = md.match(
/### DIRECTOR DETAILS\s*\n([\s\S]*?)(?=\n### |\n*$)/i
);
if (dir) {
let seenHeader = false;
for (const line of dir[1].split(/\r?\n/)) {
const t = line.trim();
if (!t.startsWith("|")) continue;
const cells = t.split("|").slice(1, -1).map((c) => c.trim());
// Alignment row "|----|----|"
if (cells.every((c) => /^[-:]+$/.test(c))) continue;
if (!seenHeader) {
seenHeader = true;
continue;
}
// Layout: [ID Number | Name(s) | Surname | Type | Status]
const id = cells[0] ?? "";
if (!id) continue;
const first = cells[1] ?? "";
const last = cells[2] ?? "";
const fullName = [first, last].filter(Boolean).join(" ");
out.directors.push({ name: fullName, id });
}
}
return out;
}
/** Fetch the full PB record for a given Enterprise Number. Returns null
* when not found. Uses the same normalisation helper as the rest of the
* route (K/M/B prefix, digits-only, slash-form legacy). */
async function fetchFullPbRecord(
kNo: string,
token: string,
): Promise<Record<string, any> | null> {
if (!token) return null;
for (const f of normalizeEnterpriseNoForms(kNo)) {
const filter = `enterprise_no="${f.replace(/"/g, '\\"')}"`;
const url =
`https://pb.agent.apphub.co.za/api/collections/ysterbaard_companies/records` +
`?perPage=1&filter=${encodeURIComponent(filter)}`;
try {
const r = await fetch(url, {
headers: { Authorization: token },
signal: AbortSignal.timeout(8000),
});
if (!r.ok) continue;
const j = await r.json();
const item = j?.items?.[0];
if (item?.id) return item;
} catch {}
}
return null;
}
function badRequest(message: string) {
return NextResponse.json({ ok: false, error: message }, { status: 400 });
}
// ===================================================================
// RUNTIME CAPABILITY CHECK
// ===================================================================
// CIPC/BizPortal search is not an ordinary server-side job: it drives a
// real Chrome session through agent-browser to log into BizPortal and read
// the result pane. That toolchain exists only on the Windows dev machine.
// The Coolify image is a Linux container with no browser tooling at all —
// see the NOTE at the top of the Dockerfile, which says exactly this.
//
// Until 2026-10-20 this was not detected up front. The request fell
// through to the legacy `spawnSync` fallback, which builds a Windows-style
// interpreter path even on Linux (`.venv/Scripts/` + `python`), so `/bin/sh`
// answered "not found" with exit code 127, `extractError()` found no ERROR:
// line in stdout and returned "Unknown error", and the UI rendered that over
// a raw shell message. A dead end for anyone who tried the search on the
// deployed URL.
//
// Returns a plain-language reason when the runtime can't be used here, or
// null when it can. Re-checked per request so it never goes stale.
function cipcUnavailableReason(): string | null {
const isWindows = process.platform === "win32";
const pythonBin = isWindows
? path.join(SCRIPTS_DIR, ".venv", "Scripts", "python.exe")
: path.join(SCRIPTS_DIR, ".venv", "bin", "python");
if (!fs.existsSync(pythonBin)) {
return (
"CIPC search runs on the local Workflow app on your PC (npm run dev), " +
"not on the deployed server — this server has no BizPortal browser " +
`runtime installed (no Python at ${pythonBin}). ` +
"Open the app on your machine to run this search."
);
}
return null;
}
/** Extract the markdown block from the full stdout. */
function extractMarkdown(stdout: string): string {
const idx = stdout.indexOf("\n#");
if (idx === -1) {
const hashIdx = stdout.indexOf("# ");
if (hashIdx === -1) return "";
return stdout.slice(hashIdx).trim();
}
return stdout.slice(idx + 1).trim();
}
function extractCounter(stdout: string): number | undefined {
const m = stdout.match(/Searches Conducted Today:\s*(\d+)/);
return m ? Number(m[1]) : undefined;
}
function extractError(stdout: string): string {
const lines = stdout.split("\n");
const errors = lines.filter((l) => l.trim().startsWith("ERROR:") || l.includes("STOP:"));
if (errors.length > 0) return errors.map((l) => l.trim()).join(" · ");
const lastMeaningful = lines.filter((l) => l.trim()).slice(-3).join(" | ");
return lastMeaningful || "Unknown error";
}
export async function POST(req: NextRequest) {
// Make sure the fast path exists before we decide which path to take.
// Fire-and-forget: never delays this request.
void ensureWrapperService();
let body: { kind?: string; query?: string };
try {
body = await req.json();
} catch {
return badRequest("Invalid JSON body");
}
const kind = body.kind;
const rawQuery = (body.query || "").trim();
if (kind !== "enterprise_no" && kind !== "director_id_no") {
return badRequest("kind must be 'enterprise_no' or 'director_id_no'");
}
if (!rawQuery) return badRequest("query is required");
// Refuse early, and in words the operator can act on, when the browser
// runtime this route needs is not present on the host (the deployed Linux
// container). Better a clear 503 than a 165 s hang or a shell error.
const unavailable = cipcUnavailableReason();
if (unavailable) {
return NextResponse.json(
{
ok: false,
error: unavailable,
raw: unavailable,
search_kind: kind,
query: rawQuery,
runtime_missing: true,
},
{ status: 503 }
);
}
let resolvedQuery = rawQuery;
let resolvedVia: "k_number" | "company_name" | "director_id" | null = null;
// Enterprise No. search: if input looks like a K-number, try to resolve to a
// company name via PocketBase. If PB doesn't know it, forward the raw K to
// the script anyway (the .py will fail clearly with "no PB record matched"
// — which is the correct error for an unknown K).
if (kind === "enterprise_no" && isEnterpriseNo(rawQuery)) {
const name = await resolveEnterpriseNoToName(rawQuery);
if (name) {
resolvedQuery = name;
resolvedVia = "k_number";
} else {
resolvedVia = null; // forward raw; .py will report its own PB error
}
} else if (kind === "enterprise_no") {
resolvedVia = "company_name";
} else if (kind === "director_id_no") {
resolvedVia = "director_id";
}
const scriptName = SCRIPTS[kind];
const scriptPath = path.join(SCRIPTS_DIR, scriptName);
const venvPython = path.join(
SCRIPTS_DIR,
".venv",
"Scripts",
process.platform === "win32" ? "python.exe" : "python"
);
const start = Date.now();
// WRAPPER-FIRST: try the long-lived _cipc_service.js on 127.0.0.1:6002.
// On connect-refused / socket error / wrapper timeout, fall through to the
// legacy `spawnSync` path so the route stays functional even if the
// service is offline.
const wrapped = await callWrapper(kind, resolvedQuery, 165_000);
let duration: number;
let stdout: string;
let stderr: string;
let exitCode: number | null;
let wrapperUsed: boolean;
if (wrapped.ok) {
duration = wrapped.duration_ms ?? Date.now() - start;
stdout = wrapped.stdout || "";
stderr = wrapped.stderr || "";
exitCode = wrapped.exitCode ?? 0;
wrapperUsed = true;
} else if (
// Treat "service offline" errors as a fall-back trigger. The wrapper's
// own error message contains "connect" or "closed without response"
// or "socket error" — we look for those. Anything else is a real
// failure (e.g. BizPortal subscription expired mid-search).
/connect|ECONNREFUSED|closed without response|wrapper socket error/i.test(
wrapped.error || ""
)
) {
// Fall through to legacy spawnSync below.
const quoted = [
`"${venvPython.replace(/"/g, '\\"')}"`,
`"${scriptPath.replace(/"/g, '\\"')}"`,
`"${resolvedQuery.replace(/"/g, '\\"')}"`,
].join(" ");
let result: ReturnType<typeof spawnSync>;
try {
result = spawnSync(quoted, {
cwd: SCRIPTS_DIR,
encoding: "utf-8",
maxBuffer: 32 * 1024 * 1024,
shell: true,
windowsHide: true,
// A cold search legitimately takes 70-90s (full BizPortal login plus
// the 35s result-pane wait). Past LEGACY_SPAWN_TIMEOUT_MS something
// is wedged — each agent-browser step has its own 60s cap, so a
// broken page stacks several of them. Kill it and say so instead of
// blocking the request forever. Without this, a hung search left the
// UI spinning indefinitely (seen 2026-10-09).
timeout: LEGACY_SPAWN_TIMEOUT_MS,
killSignal: "SIGKILL",
});
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: `Failed to spawn ${scriptName}: ${err?.message || String(err)}` },
{ status: 500 }
);
}
// spawnSync reports a timeout via `.error` (ETIMEDOUT), not a non-zero
// exit code, so this needs handling separately.
if (result.error && (result.error as any).code === "ETIMEDOUT") {
duration = Date.now() - start;
return NextResponse.json(
{
ok: false,
error:
`The CIPC search service is not responding — the lookup was stopped after ${Math.round(
LEGACY_SPAWN_TIMEOUT_MS / 1000
)}s. ` +
"This is the slow fallback path doing a cold login; it usually means the CIPC wrapper " +
"service on port 6002 is down. Restart the app (it autostarts now) and try again.",
duration_ms: duration,
search_kind: kind,
query: rawQuery,
resolved_query: resolvedQuery,
resolved_via: resolvedVia,
timed_out: true,
used_slow_path: true,
},
{ status: 504 }
);
}
duration = Date.now() - start;
// `result` is typed as the un-overloaded spawnSync return, so stdout/stderr
// are `string | Buffer`. encoding: "utf-8" is set above, but toString()
// is a no-op on a string and the correct decode if that ever changes.
stdout = result.stdout?.toString() ?? "";
stderr = result.stderr?.toString() ?? "";
exitCode = result.status;
wrapperUsed = false;
} else {
// Wrapper was reachable but rejected the request (real failure).
return NextResponse.json(
{
ok: false,
error: wrapped.error || "wrapper error",
raw: (wrapped.stdout || "") + (wrapped.stderr ? `\n\n--- stderr ---\n${wrapped.stderr}` : ""),
duration_ms: wrapped.duration_ms ?? Date.now() - start,
search_kind: kind,
query: rawQuery,
resolved_query: resolvedQuery,
resolved_via: resolvedVia,
...(wrapped.warmed_up !== undefined ? { warmed_up: wrapped.warmed_up } : {}),
},
{ status: 500 }
);
}
// If we got here via legacy spawnSync, exitCode holds result.status (number).
// If we got here via wrapper, exitCode holds exitCode from python (.py).
if (exitCode !== 0) {
return NextResponse.json(
{
ok: false,
error: extractError(stdout) || `${scriptName} exited with code ${exitCode}`,
raw: stdout + (stderr ? `\n\n--- stderr ---\n${stderr}` : ""),
duration_ms: duration,
search_kind: kind,
query: rawQuery,
resolved_query: resolvedQuery,
resolved_via: resolvedVia,
wrapper_used: wrapperUsed,
},
{ status: 500 }
);
}
const markdown = extractMarkdown(stdout);
const counter = extractCounter(stdout);
// Enrichment: ask PB whether each returned Enterprise Number already exists
// in `ysterbaard_companies`. The .py scripts emit the canonical K/M/B form
// so this matches cleanly most of the time; the OR filter also covers
// legacy slash formats that PB may store.
const entNos = extractEnterpriseNos(markdown, kind);
const pbToken = readPbToken();
const collection_matches = await collectCollectionMatches(entNos, pbToken);
// For Enterprise No. searches, also extract the structured CIPC body
// (ENTERPRISE DETAILS + DIRECTOR DETAILS) and fetch the full PB record if
// the company exists. The UI uses both to render a field-by-field diff
// with one-click PATCHes.
let pb_record: Record<string, any> | null = null;
let cipc_fields: CipcFields | null = null;
let cipc_directors: Array<{ name: string; id: string }> | null = null;
if (kind === "enterprise_no") {
cipc_fields = extractCipcFields(markdown);
cipc_directors = cipc_fields.directors;
const kNo = entNos[0];
const matched = kNo ? collection_matches[kNo.toUpperCase()] : null;
if (matched?.in_collection && matched.pb_id) {
pb_record = await fetchFullPbRecord(kNo, pbToken);
}
}
return NextResponse.json({
ok: true,
markdown,
raw: stdout,
duration_ms: duration,
counter,
search_kind: kind,
query: rawQuery,
resolved_query: resolvedQuery,
resolved_via: resolvedVia,
collection_matches,
wrapper_used: wrapperUsed,
...(kind === "enterprise_no"
? {
pb_record,
cipc_fields: cipc_fields?.kv ?? {},
cipc_directors,
}
: {}),
});
}