Workflow-App/Workflow/app/api/cipc/search/route.ts
sjcoetzeesj-coder 92dcae5122 fix(cipc): stop deadlocking wrapper on agent-browser daemon start
runAb() in scripts/_cipc_service.js resolved only on proc.on('close'),
but agent-browser is a daemon-per-session CLI. When it has to START the
session browser daemon (first call after a reboot, after the daemon died,
or after the app restarts), the new daemon inherits the spawned CLI
stdout pipe. The pipe then never reaches EOF, so Node 'close' event
never fires - even though the CLI exited cleanly with code 0. The 30s
timeout in runAb called proc.kill(SIGTERM), but the process had already
exited, so the kill was a no-op and the promise never settled. The
wrapper serialised mutex then held inFlight forever, every later request
queued behind it, and CIPC search silently 500-ed for days with no log
line to show why - which is what made this look like a flaky / restart-
only issue.

Fixes:
  - runAb now settles on 'exit' (with a 250ms flush grace for stdio to
    drain), escalates the timeout kill to SIGKILL, and guarantees an
    unconditional forced settle if neither fires. The promise can no
    longer remain pending.
  - app/api/cipc/search/route.ts: ensureWrapperService() now probes port
    6002 on every search and respawns the wrapper (rate-limited to once
    per 30s) if it is down. Hot reloads still no-op once a wrapper is up,
    but a dead wrapper now self-heals instead of 500-ing forever.

Verified end to end: killed the browser daemon and the wrapper, started
a fresh wrapper, its first isWarm() spawned a new daemon (the previous
deadlock path) and returned in under 1s. A real enterprise search of
K2018435959 came back in 31.7s on the cold path and 50.9s on the warm
path with full company + director data.
2026-10-19 13:40:36 +02:00

744 lines
27 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 });
}
/** 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");
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,
}
: {}),
});
}