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.
744 lines
27 KiB
TypeScript
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,
|
|
}
|
|
: {}),
|
|
});
|
|
}
|