@lincoln504/pi-research 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +1334 -750
- package/dist/thread-worker.mjs +194 -136
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +114 -16
- package/package.json +1 -1
- package/scripts/cleanup.cjs +64 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +228 -70
- package/src/config.ts +78 -2
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +10 -2
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/healthcheck/index.ts +7 -2
- package/src/infrastructure/browser/browser-client.ts +64 -9
- package/src/infrastructure/browser/browser-server.ts +24 -7
- package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
- package/src/infrastructure/browser/cleanup-utils.ts +50 -2
- package/src/infrastructure/browser/client-agent.ts +6 -6
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/scheduler-factory.ts +18 -3
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +194 -136
- package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
- package/src/infrastructure/embedding/embedding-factory.ts +35 -6
- package/src/infrastructure/embedding/embedding-server.ts +40 -7
- package/src/infrastructure/file-lock-service.ts +72 -2
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +28 -5
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +4 -4
- package/src/infrastructure/state/state-manager.ts +15 -3
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder.ts +136 -43
- package/src/knowledge/store.ts +66 -12
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +12 -2
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/research-orchestration-service.ts +63 -11
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +7 -2
- package/src/skill-install/skill-installer.ts +18 -2
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +35 -1
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +10 -1
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +26 -7
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +6 -2
- package/src/web-research/types.ts +4 -2
- package/src/web-research/web-scraper.ts +65 -4
- package/src/core/health-check-service.ts +0 -265
|
@@ -202,9 +202,16 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
202
202
|
|
|
203
203
|
async startServer(): Promise<number> {
|
|
204
204
|
this.server = new BrowserServer({
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
// The per-request signal aborts when the follower's socket closes
|
|
206
|
+
// before completion, so the leader-side task releases its queue
|
|
207
|
+
// claim (abort-while-queued/running is the PriorityTaskQueue's own
|
|
208
|
+
// pinned semantics) instead of holding the slot to full timeout.
|
|
209
|
+
// Healthcheck mirrors search/scrape here even though its one current
|
|
210
|
+
// production caller (src/healthcheck/index.ts) never passes a signal —
|
|
211
|
+
// the wiring must not silently drop one the moment a caller does.
|
|
212
|
+
onSearch: (q, signal) => this.runSearch(q, undefined, signal),
|
|
213
|
+
onScrape: (u, signal) => this.runScrape(u, undefined, signal),
|
|
214
|
+
onHealthCheck: (signal) => this.runHealthCheck(undefined, signal),
|
|
208
215
|
});
|
|
209
216
|
// FIX (#21): Expose auth secret to child processes via env
|
|
210
217
|
process.env['PI_BROWSER_AUTH_SECRET'] = getBrowserServerAuthSecret();
|
|
@@ -273,6 +280,18 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
273
280
|
logger.debug(`[BrowserTaskScheduler] Search abandoned during shutdown: "${query}"`);
|
|
274
281
|
throw error;
|
|
275
282
|
}
|
|
283
|
+
// The caller's own signal aborting (follower socket closed / research run
|
|
284
|
+
// cancelled) is what PriorityTaskQueue's abort-while-queued/running
|
|
285
|
+
// rejection above reports. This is routine, expected cancellation, not a
|
|
286
|
+
// fault — cancelling a run routinely cancels its in-flight browser tasks,
|
|
287
|
+
// so this fires often. Checking the signal directly (rather than matching
|
|
288
|
+
// the queue's rejection text) mirrors how BrowserServer's own catch block
|
|
289
|
+
// and task-execution-service.ts already classify this. Demote to DEBUG so
|
|
290
|
+
// it doesn't inflate ERROR counts / browser_search_errors_total.
|
|
291
|
+
if (signal?.aborted) {
|
|
292
|
+
logger.debug(`[BrowserTaskScheduler] Search cancelled by caller: "${query}"`);
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
276
295
|
logger.error(`[BrowserTaskScheduler] Search failed: "${query}"`, error);
|
|
277
296
|
metrics.increment('browser_search_errors_total', 1);
|
|
278
297
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
@@ -288,8 +307,10 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
288
307
|
}
|
|
289
308
|
|
|
290
309
|
const duration = Date.now() - startTime;
|
|
291
|
-
|
|
292
|
-
|
|
310
|
+
// In-band worker error checked FIRST: a task the worker reports as failed
|
|
311
|
+
// must count only as an error. Recording success unconditionally before
|
|
312
|
+
// this check double-counted every worker-reported failure as
|
|
313
|
+
// success+error, so dashboards read healthy through a full outage.
|
|
293
314
|
if (result.error) {
|
|
294
315
|
metrics.increment('browser_search_requests_total', 1, { status: 'error' });
|
|
295
316
|
errorTracker.trackError(new Error(result.error), {
|
|
@@ -301,6 +322,8 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
301
322
|
});
|
|
302
323
|
throw new Error(result.error);
|
|
303
324
|
}
|
|
325
|
+
metrics.observe('browser_search_duration_ms', duration, { status: 'success' });
|
|
326
|
+
metrics.increment('browser_search_requests_total', 1, { status: 'success' });
|
|
304
327
|
return result.results;
|
|
305
328
|
}
|
|
306
329
|
|
|
@@ -347,6 +370,12 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
347
370
|
logger.debug(`[BrowserTaskScheduler] Scrape abandoned during shutdown: "${url}"`);
|
|
348
371
|
throw error;
|
|
349
372
|
}
|
|
373
|
+
// See runSearch: the caller's own signal aborting is routine cancellation
|
|
374
|
+
// (follower disconnect / run cancelled), not a fault — demote to DEBUG.
|
|
375
|
+
if (signal?.aborted) {
|
|
376
|
+
logger.debug(`[BrowserTaskScheduler] Scrape cancelled by caller: "${url}"`);
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
350
379
|
metrics.increment('browser_scrape_errors_total', 1);
|
|
351
380
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
352
381
|
component: 'browser-manager',
|
|
@@ -360,8 +389,7 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
360
389
|
}
|
|
361
390
|
|
|
362
391
|
const duration = Date.now() - startTime;
|
|
363
|
-
|
|
364
|
-
metrics.increment('browser_scrape_requests_total', 1, { status: 'success' });
|
|
392
|
+
// In-band error first — success-side recording must not precede it (see runSearch).
|
|
365
393
|
if (result.error) {
|
|
366
394
|
metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
|
|
367
395
|
errorTracker.trackError(new Error(result.error), {
|
|
@@ -372,6 +400,8 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
372
400
|
});
|
|
373
401
|
throw new Error(result.error);
|
|
374
402
|
}
|
|
403
|
+
metrics.observe('browser_scrape_duration_ms', duration, { status: 'success' });
|
|
404
|
+
metrics.increment('browser_scrape_requests_total', 1, { status: 'success' });
|
|
375
405
|
return result;
|
|
376
406
|
}
|
|
377
407
|
|
|
@@ -412,6 +442,13 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
412
442
|
logger.debug(`[BrowserTaskScheduler] Healthcheck abandoned during shutdown`);
|
|
413
443
|
throw error;
|
|
414
444
|
}
|
|
445
|
+
// See runSearch: the caller's own signal aborting is routine cancellation,
|
|
446
|
+
// not a fault — demote to DEBUG. Reachable now that startServer() forwards
|
|
447
|
+
// a signal into onHealthCheck (see BrowserServer's onHealthCheck wiring).
|
|
448
|
+
if (signal?.aborted) {
|
|
449
|
+
logger.debug(`[BrowserTaskScheduler] Healthcheck cancelled by caller`);
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
415
452
|
metrics.increment('browser_healthcheck_errors_total', 1);
|
|
416
453
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
417
454
|
component: 'browser-manager',
|
|
@@ -425,10 +462,10 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
425
462
|
}
|
|
426
463
|
|
|
427
464
|
const duration = Date.now() - startTime;
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
465
|
+
// In-band error first — success-side recording must not precede it (see
|
|
466
|
+
// runSearch). This path additionally owns the browser_pool_health gauge:
|
|
467
|
+
// setting it to 1 before the check flickered the pool healthy on every
|
|
468
|
+
// failed probe during an outage.
|
|
432
469
|
if (result.error) {
|
|
433
470
|
metrics.increment('browser_healthcheck_requests_total', 1, { status: 'error' });
|
|
434
471
|
metrics.setGauge('browser_pool_health', 0);
|
|
@@ -440,6 +477,10 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
440
477
|
});
|
|
441
478
|
throw new Error(result.error);
|
|
442
479
|
}
|
|
480
|
+
metrics.observe('browser_healthcheck_duration_ms', duration, { status: 'success' });
|
|
481
|
+
metrics.increment('browser_healthcheck_requests_total', 1, { status: 'success' });
|
|
482
|
+
metrics.setGauge('browser_pool_health', 1);
|
|
483
|
+
logger.debug(`[Scheduler] Healthcheck completed in ${duration}ms`);
|
|
443
484
|
return result;
|
|
444
485
|
}
|
|
445
486
|
|
|
@@ -505,7 +546,12 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
505
546
|
logger.warn('[Scheduler] Could not read browser server state during shutdown:', err);
|
|
506
547
|
}
|
|
507
548
|
if (serverInfo?.pid === process.pid && serverInfo?.schedulerId === this.schedulerId) {
|
|
508
|
-
|
|
549
|
+
// CAS: this belief-check (pid + schedulerId match) happens outside any
|
|
550
|
+
// lock, so pass it as `expected` rather than relying on it as an
|
|
551
|
+
// assumption — if a successor claimed the slot between the read above
|
|
552
|
+
// and this clear, the mismatch makes the clear a no-op instead of
|
|
553
|
+
// deregistering the new owner.
|
|
554
|
+
await this.stateManager.clearBrowserServer({ pid: process.pid, schedulerId: this.schedulerId }).catch((err) => {
|
|
509
555
|
logger.warn('[Scheduler] Failed to clear browser server state during shutdown:', err);
|
|
510
556
|
});
|
|
511
557
|
}
|
|
@@ -108,6 +108,48 @@ async function isProfileLive(profileDir: string): Promise<boolean> {
|
|
|
108
108
|
return false;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** Canonicalize a path for identity comparison: realpath when resolvable (macOS
|
|
112
|
+
* /tmp is a symlink to /private/tmp), else a plain resolve; case-folded on
|
|
113
|
+
* Windows, whose filesystem compares case-insensitively. */
|
|
114
|
+
async function canonicalizeForCompare(p: string): Promise<string> {
|
|
115
|
+
let resolved: string;
|
|
116
|
+
try {
|
|
117
|
+
resolved = await fs.realpath(p);
|
|
118
|
+
} catch {
|
|
119
|
+
resolved = path.resolve(p);
|
|
120
|
+
}
|
|
121
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* May the unrecognized-entry deletion branch run against this explicit baseDir?
|
|
126
|
+
*
|
|
127
|
+
* That branch recursively rm's ANY stale subdirectory, so it is only safe inside
|
|
128
|
+
* a directory pi-research owns outright. Two conditions gate it: the dir must not
|
|
129
|
+
* BE the shared system temp dir (realpath-compared on both sides — a
|
|
130
|
+
* PI_RESEARCH_TMP_DIR pointed at /tmp once made every same-user /tmp entry — ssh
|
|
131
|
+
* agents, build caches — a sweep candidate), and the path must carry a
|
|
132
|
+
* pi-research segment as positive evidence of ownership. When this returns false
|
|
133
|
+
* the prefix-matched playwright profile cleanup still runs; only unknowns are
|
|
134
|
+
* spared.
|
|
135
|
+
*/
|
|
136
|
+
async function isPiOwnedSweepDir(dir: string): Promise<boolean> {
|
|
137
|
+
const [realDir, realTmp] = await Promise.all([
|
|
138
|
+
canonicalizeForCompare(dir),
|
|
139
|
+
canonicalizeForCompare(os.tmpdir()),
|
|
140
|
+
]);
|
|
141
|
+
if (realDir === realTmp) return false;
|
|
142
|
+
const hasPiSegment = (p: string) =>
|
|
143
|
+
p.split(/[\\/]+/).some((segment) => segment.toLowerCase().startsWith('pi-research'));
|
|
144
|
+
// Only the CANONICAL path counts. A symlinked dir whose target lacks the
|
|
145
|
+
// marker is NOT sweep-safe: approving it by its configured name would send the
|
|
146
|
+
// recursive rm THROUGH the symlink into a directory we do not own (e.g. a
|
|
147
|
+
// /tmp/pi-research/profiles link pre-created by another user under a
|
|
148
|
+
// world-writable PI_RESEARCH_TMP_DIR). Unknowns are spared; the prefix-matched
|
|
149
|
+
// playwright profile cleanup still runs either way.
|
|
150
|
+
return hasPiSegment(realDir);
|
|
151
|
+
}
|
|
152
|
+
|
|
111
153
|
/**
|
|
112
154
|
* Reclaim leaked Playwright/Camoufox profile directories.
|
|
113
155
|
*
|
|
@@ -134,6 +176,11 @@ export async function cleanupStaleProfiles(
|
|
|
134
176
|
let removed = 0;
|
|
135
177
|
let errors = 0;
|
|
136
178
|
|
|
179
|
+
// Explicit-baseDir mode considers EVERY entry a candidate, including ones it
|
|
180
|
+
// does not recognize; deleting unknowns is only permitted inside a dir that is
|
|
181
|
+
// provably pi-owned and not the shared system temp dir (see isPiOwnedSweepDir).
|
|
182
|
+
const sweepUnrecognized = usePrefix ? false : await isPiOwnedSweepDir(scanDir);
|
|
183
|
+
|
|
137
184
|
try {
|
|
138
185
|
const entries = await fs.readdir(scanDir);
|
|
139
186
|
const now = Date.now();
|
|
@@ -171,8 +218,9 @@ export async function cleanupStaleProfiles(
|
|
|
171
218
|
}
|
|
172
219
|
|
|
173
220
|
// Unrecognized entry (only reachable with an explicit baseDir): be conservative —
|
|
174
|
-
// only reclaim if both old AND not live,
|
|
175
|
-
|
|
221
|
+
// only reclaim inside a pi-owned dir, and only if both old AND not live,
|
|
222
|
+
// so nothing unknown is ever deleted promptly or outside our ownership.
|
|
223
|
+
if (sweepUnrecognized && now - stats.mtimeMs > staleMs && !(await isProfileLive(fullPath))) {
|
|
176
224
|
await fs.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
177
225
|
removed++;
|
|
178
226
|
}
|
|
@@ -10,17 +10,17 @@ import * as http from 'node:http';
|
|
|
10
10
|
/**
|
|
11
11
|
* Global HTTP Agent for high-concurrency client requests
|
|
12
12
|
*
|
|
13
|
-
* IMPORTANT: The socket timeout must
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* IMPORTANT: The socket timeout must exceed the largest possible BrowserClient
|
|
14
|
+
* request timeout (CLIENT_TIMEOUT_CAP_MS = 300s in browser-client.ts) so the
|
|
15
|
+
* request-level timer — whose error text is classified by the retry gates —
|
|
16
|
+
* always answers before the socket layer tears the connection down.
|
|
16
17
|
*/
|
|
17
18
|
const clientAgent = new http.Agent({
|
|
18
19
|
keepAlive: true,
|
|
19
20
|
maxSockets: 100, // Allow up to 100 concurrent requests to the leader
|
|
20
21
|
maxFreeSockets: 10,
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
timeout: 180000
|
|
22
|
+
// Above CLIENT_TIMEOUT_CAP_MS (300s) with margin — see the invariant above.
|
|
23
|
+
timeout: 330000
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -61,8 +61,9 @@ export function getBrowserCacheDir(): string {
|
|
|
61
61
|
* os.tmpdir(): on systems where /tmp is tmpfs (RAM-backed), placing several
|
|
62
62
|
* browser profiles there consumes RAM and, with an OOM killer like earlyoom
|
|
63
63
|
* active, can contribute to the whole session being killed. Override with
|
|
64
|
-
* PI_RESEARCH_TMP_DIR (config.TMP_DIR) — e.g. point it
|
|
65
|
-
*
|
|
64
|
+
* PI_RESEARCH_TMP_DIR (config.TMP_DIR) — e.g. point it at the system temp dir
|
|
65
|
+
* to deliberately use tmpfs/RAM when there is memory headroom; profiles then
|
|
66
|
+
* land in <TMP_DIR>/pi-research/profiles, never in TMP_DIR itself.
|
|
66
67
|
*
|
|
67
68
|
* The directory is created if missing, because Playwright requires the parent
|
|
68
69
|
* of its mkdtemp profile dir to already exist.
|
|
@@ -71,7 +72,17 @@ export function getBrowserProfileDir(config?: Config): string {
|
|
|
71
72
|
const configured = (config || getConfig()).TMP_DIR;
|
|
72
73
|
let dir: string;
|
|
73
74
|
if (configured && configured.trim().length > 0) {
|
|
74
|
-
|
|
75
|
+
// Containment invariant: profiles live in a pi-research-owned
|
|
76
|
+
// SUBDIRECTORY of the configured dir, never the configured dir itself.
|
|
77
|
+
// The pool-startup sweep (cleanupStaleProfiles with an explicit baseDir)
|
|
78
|
+
// treats every entry of this directory as a reclaim candidate, so using
|
|
79
|
+
// PI_RESEARCH_TMP_DIR verbatim — e.g. pointed at /tmp per the tmpfs
|
|
80
|
+
// opt-in above — put unrelated same-user directories (ssh agents, build
|
|
81
|
+
// caches) on the sweep's candidate list. The pi-research/profiles shape
|
|
82
|
+
// also matches PI_BROWSER_MARKER's profile-path fallback in
|
|
83
|
+
// browser-cleanup.ts, so orphan detection keeps working for relocated
|
|
84
|
+
// profiles.
|
|
85
|
+
dir = join(configured, 'pi-research', 'profiles');
|
|
75
86
|
} else {
|
|
76
87
|
const cacheHome = process.env['XDG_CACHE_HOME'] || join(homedir(), '.cache');
|
|
77
88
|
dir = join(cacheHome, 'pi-research', 'profiles');
|
|
@@ -132,7 +132,16 @@ export async function forceSchedulerRestart(forceClearRemoteState: boolean = fal
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
if (shouldClearState) {
|
|
135
|
-
|
|
135
|
+
// Compare-and-delete against the registration we just read: without a
|
|
136
|
+
// lock spanning the read-decide-clear sequence above, a concurrent
|
|
137
|
+
// process can independently detect the same dead leader, complete its
|
|
138
|
+
// own (properly locked) election in getScheduler(), and publish a
|
|
139
|
+
// fresh registration in this exact window. An unconditional clear here
|
|
140
|
+
// would wipe that brand-new, legitimate leader — it keeps running but
|
|
141
|
+
// becomes undiscoverable via state, so the next caller elects a
|
|
142
|
+
// second, redundant leader. See StateBrowserManager.clearBrowserServer.
|
|
143
|
+
const expected = serverInfo ? { pid: serverInfo.pid, schedulerId: serverInfo.schedulerId } : undefined;
|
|
144
|
+
await stateManager.clearBrowserServer(expected).catch((error) => {
|
|
136
145
|
logger.warn('[Scheduler] Failed to clear browser server from state:', error);
|
|
137
146
|
});
|
|
138
147
|
}
|
|
@@ -222,12 +231,18 @@ export async function getScheduler(config?: Config, container: ServiceContainer
|
|
|
222
231
|
if (serverInfo.pid !== process.pid) {
|
|
223
232
|
logger.log(`[Scheduler] Bypassing stale scheduler process (PID ${serverInfo.pid}) by clearing state...`);
|
|
224
233
|
}
|
|
225
|
-
|
|
234
|
+
// Defense-in-depth CAS: this read-then-clear is entirely inside
|
|
235
|
+
// the browser-init lock, which serializes other getScheduler()
|
|
236
|
+
// callers, but callers that clear without taking this lock
|
|
237
|
+
// (forceSchedulerRestart, scheduler shutdown) are not excluded —
|
|
238
|
+
// pass what was just read so a mismatch no-ops instead of
|
|
239
|
+
// deleting a registration this code didn't observe.
|
|
240
|
+
await stateManager.clearBrowserServer({ pid: serverInfo.pid, schedulerId: serverInfo.schedulerId });
|
|
226
241
|
} else {
|
|
227
242
|
const portOk = await isPortListening(serverInfo.port);
|
|
228
243
|
if (!portOk) {
|
|
229
244
|
logger.warn(`[Scheduler] PID ${serverInfo.pid} is alive but port ${serverInfo.port} is not listening — clearing stale state and starting fresh.`);
|
|
230
|
-
await stateManager.clearBrowserServer().catch((e) => {
|
|
245
|
+
await stateManager.clearBrowserServer({ pid: serverInfo.pid, schedulerId: serverInfo.schedulerId }).catch((e) => {
|
|
231
246
|
logger.warn('[Scheduler] Failed to clear stale browser server state:', e);
|
|
232
247
|
});
|
|
233
248
|
} else {
|
|
@@ -8,6 +8,7 @@ let workerId: string = '';
|
|
|
8
8
|
|
|
9
9
|
import { appendFileSync } from 'node:fs';
|
|
10
10
|
import { redactSecrets } from '../../utils/log-utils.ts';
|
|
11
|
+
import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Mutex lock to serialize page creation. Playwright/Firefox can deadlock if newPage()
|
|
@@ -311,7 +312,7 @@ export async function executeScrapeTask(
|
|
|
311
312
|
_context: any,
|
|
312
313
|
url: string,
|
|
313
314
|
signal?: AbortSignal
|
|
314
|
-
): Promise<{ contentType: string; html?: string;
|
|
315
|
+
): Promise<{ contentType: string; html?: string; bufferB64?: string; jitter: number }> {
|
|
315
316
|
const page = await createPageSafe(_context);
|
|
316
317
|
const SCRAPE_TIMEOUT = parseInt(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'] || '15000', 10);
|
|
317
318
|
page.setDefaultTimeout(SCRAPE_TIMEOUT);
|
|
@@ -458,15 +459,47 @@ export async function executeScrapeTask(
|
|
|
458
459
|
}
|
|
459
460
|
|
|
460
461
|
const contentType = (await response?.headerValue('content-type')) || '';
|
|
462
|
+
// HTTP status of the final navigation response. Never consulting it let
|
|
463
|
+
// 404/500/403 pages return as normal {contentType, html} results — major
|
|
464
|
+
// sites' rich error pages cleared the stub gate, were cached as scrape
|
|
465
|
+
// SUCCESS, and became citable. The fetch layer enforces !response.ok; this
|
|
466
|
+
// layer must match it (`HTTP <status>` is benign-classified per URL). The
|
|
467
|
+
// one legitimate exception is a Cloudflare challenge, which arrives with
|
|
468
|
+
// 403/503 — the HTML branch defers to the challenge wait below for that.
|
|
469
|
+
const status = response?.status?.() ?? 0;
|
|
461
470
|
|
|
462
471
|
if (contentType.includes('application/pdf')) {
|
|
463
472
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
473
|
+
// An errored "PDF" is an error page: fail BEFORE buffering its body.
|
|
474
|
+
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
475
|
+
// Enforce the parent's PDF cap HERE, not only after the IPC transfer:
|
|
476
|
+
// buffering a multi-hundred-MB PDF (plus the base64 copy below) in the
|
|
477
|
+
// worker can OOM-kill the process — stranding every sibling task — or hit
|
|
478
|
+
// ERR_STRING_TOO_LONG. Content-Length first (free when present, and it
|
|
479
|
+
// skips the read entirely)...
|
|
480
|
+
const declaredLength = parseInt((await response.headerValue('content-length').catch(() => null)) || '', 10);
|
|
481
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_PDF_SIZE) {
|
|
482
|
+
const sizeMB = Math.round(declaredLength / 1024 / 1024);
|
|
483
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
484
|
+
}
|
|
464
485
|
const buffer = await response.body();
|
|
486
|
+
// ...then the actual bytes: the header is advisory and absent on chunked
|
|
487
|
+
// responses, so the byte count is the check that actually bounds memory.
|
|
488
|
+
// Checked BEFORE toString('base64') doubles the allocation.
|
|
489
|
+
if (buffer.byteLength > MAX_PDF_SIZE) {
|
|
490
|
+
const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
|
|
491
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
492
|
+
}
|
|
465
493
|
// Discard the bytes if any observed response came from a private address.
|
|
466
494
|
await Promise.allSettled(pendingAddrChecks);
|
|
467
495
|
if (poisonedError) throw poisonedError;
|
|
468
496
|
await page.close();
|
|
469
|
-
|
|
497
|
+
// Base64, never a raw Buffer: this result crosses the poolifier cluster IPC
|
|
498
|
+
// channel (default JSON serialization), where a Buffer arrives as
|
|
499
|
+
// {type:'Buffer',data:[...]} — and the follower path adds a second JSON hop
|
|
500
|
+
// through the leader's browser-server. A base64 string survives both hops
|
|
501
|
+
// verbatim (and is ~3x smaller on the wire than the data-array form).
|
|
502
|
+
return { contentType, bufferB64: buffer.toString('base64'), jitter: 0 };
|
|
470
503
|
}
|
|
471
504
|
|
|
472
505
|
// If it's HTML, check if we need to wait longer (JS-heavy sites)
|
|
@@ -476,6 +509,15 @@ export async function executeScrapeTask(
|
|
|
476
509
|
const cfPatterns = ['_cf_chl_', 'cdn-cgi/challenge-platform', 'cf_chl_opt', 'Just a moment...', 'Checking your browser before accessing'];
|
|
477
510
|
const hasCloudflare = cfPatterns.some((pattern: string) => html.includes(pattern));
|
|
478
511
|
|
|
512
|
+
// HTTP-error enforcement for HTML (see the PDF branch above). A Cloudflare
|
|
513
|
+
// challenge legitimately arrives with 403/503 and the wait below may clear
|
|
514
|
+
// it to the real page, so a challenged response defers to that wait — whose
|
|
515
|
+
// failure path already throws its own 'Fetch blocked:' error. Everything
|
|
516
|
+
// else with a 4xx/5xx status is an error page, not content.
|
|
517
|
+
if (status >= 400 && !hasCloudflare) {
|
|
518
|
+
throw new Error(`HTTP ${status}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
479
521
|
if (hasCloudflare) {
|
|
480
522
|
logToDebugFile('WARN', `[Worker-${workerId}] Cloudflare challenge detected for: ${url}`);
|
|
481
523
|
|
|
@@ -521,6 +563,16 @@ export async function executeScrapeTask(
|
|
|
521
563
|
html = await page.content();
|
|
522
564
|
}
|
|
523
565
|
|
|
566
|
+
// Enforce the parent's rendered-HTML cap worker-side, BEFORE the string
|
|
567
|
+
// crosses JSON IPC (and, for followers, a second JSON hop through the
|
|
568
|
+
// leader's browser-server). The parent's identical check in web-scraper.ts
|
|
569
|
+
// runs only after the transfer — too late to protect this process from a
|
|
570
|
+
// pathological/hostile page ballooning worker memory. Same error wording.
|
|
571
|
+
if (html.length > MAX_HTML_SIZE) {
|
|
572
|
+
const sizeMB = Math.round(html.length / 1024 / 1024);
|
|
573
|
+
throw new Error(`Browser HTML too large (${sizeMB}MB, max 25MB)`);
|
|
574
|
+
}
|
|
575
|
+
|
|
524
576
|
// Skip jitter in mock mode; in real browsing, add 500–1500ms to mimic human behavior
|
|
525
577
|
const jitter = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true'
|
|
526
578
|
? 0
|
|
@@ -19,7 +19,9 @@ export interface TaskResult {
|
|
|
19
19
|
error?: string;
|
|
20
20
|
success?: boolean;
|
|
21
21
|
navMs?: number;
|
|
22
|
-
|
|
22
|
+
/** PDF bytes, base64-encoded: a raw Buffer would be mangled to
|
|
23
|
+
* {type:'Buffer',data:[...]} by the cluster-IPC JSON serialization. */
|
|
24
|
+
bufferB64?: string;
|
|
23
25
|
html?: string;
|
|
24
26
|
contentType?: string;
|
|
25
27
|
}
|