@lincoln504/pi-research 1.1.0 → 1.2.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/README.md +4 -4
- package/dist/cli.mjs +1404 -600
- package/dist/thread-worker.mjs +40 -11
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/CHANGELOG.md +107 -1
- package/docs/CONFIGURATION.md +10 -8
- package/docs/KNOWLEDGE-STORE.md +5 -4
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +63 -5
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/build.cjs +23 -5
- package/scripts/cleanup.cjs +50 -10
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +8 -5
- package/skills/pi-research/references/configuration.md +7 -3
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cli.ts +161 -21
- package/src/config.ts +56 -15
- package/src/constants.ts +31 -0
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/state-manager-interfaces.ts +1 -1
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/core/pi-version.ts +136 -0
- package/src/core/service-registry.ts +117 -8
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +16 -4
- package/src/infrastructure/browser/browser-error-utils.ts +12 -1
- package/src/infrastructure/browser/browser-server.ts +17 -5
- package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
- package/src/infrastructure/browser/cleanup-utils.ts +45 -7
- package/src/infrastructure/browser/client-agent.ts +4 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +26 -1
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker.mjs +40 -11
- package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +28 -11
- package/src/infrastructure/embedding/embedding-server.ts +65 -4
- package/src/infrastructure/file-lock-service.ts +223 -26
- package/src/infrastructure/process-lifecycle-service.ts +108 -31
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-manager-service.ts +1 -1
- package/src/infrastructure/state/state-manager.ts +19 -20
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +52 -3
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +35 -9
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/orchestration/deep-research-orchestrator.ts +13 -2
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +2 -4
- package/src/orchestration/research-synthesis-service.ts +34 -92
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/nvd-client.ts +17 -6
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +135 -9
- package/src/stackexchange/rest-client.ts +6 -2
- package/src/tools/research-knowledge-search.ts +23 -2
- package/src/tools/search.ts +6 -1
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +106 -8
- package/src/utils/http-body.ts +176 -0
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/browser-search.ts +50 -4
- package/src/web-research/retry-utils.ts +13 -1
- package/src/web-research/search.ts +20 -5
- package/src/web-research/types.ts +17 -4
- package/src/web-research/web-scraper.ts +7 -50
- package/src/youtube/transcript-client.ts +5 -1
package/dist/cli.mjs
CHANGED
|
@@ -1017,11 +1017,12 @@ function disposeAllServices(container = globalServiceContainer) {
|
|
|
1017
1017
|
function resetServiceContainer(container = globalServiceContainer) {
|
|
1018
1018
|
return container.reset();
|
|
1019
1019
|
}
|
|
1020
|
-
var initializationContext, ServiceContainer, globalServiceContainer;
|
|
1020
|
+
var DISPOSE_INFLIGHT_INIT_TIMEOUT_MS, initializationContext, ServiceContainer, globalServiceContainer;
|
|
1021
1021
|
var init_service_registry = __esm({
|
|
1022
1022
|
"src/core/service-registry.ts"() {
|
|
1023
1023
|
"use strict";
|
|
1024
1024
|
init_logger();
|
|
1025
|
+
DISPOSE_INFLIGHT_INIT_TIMEOUT_MS = 5e3;
|
|
1025
1026
|
initializationContext = new AsyncLocalStorage4();
|
|
1026
1027
|
ServiceContainer = class {
|
|
1027
1028
|
services = /* @__PURE__ */ new Map();
|
|
@@ -1113,11 +1114,26 @@ var init_service_registry = __esm({
|
|
|
1113
1114
|
throw new Error(`Service '${name}' is not registered`);
|
|
1114
1115
|
}
|
|
1115
1116
|
if (registration.instance) {
|
|
1117
|
+
if (registration.initializationPromise && caller === void 0) {
|
|
1118
|
+
await registration.initializationPromise.catch(() => {
|
|
1119
|
+
});
|
|
1120
|
+
if (!registration.instance) {
|
|
1121
|
+
return this.get(name, ctx);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1116
1124
|
if (ctx && registration.instance.initialize) {
|
|
1117
|
-
|
|
1125
|
+
const reinit = initializationContext.run(name, async () => {
|
|
1118
1126
|
await registration.instance.initialize(ctx);
|
|
1119
1127
|
return registration.instance;
|
|
1120
1128
|
});
|
|
1129
|
+
registration.initializationPromise = reinit;
|
|
1130
|
+
const clearIfCurrent = () => {
|
|
1131
|
+
if (registration.initializationPromise === reinit) {
|
|
1132
|
+
registration.initializationPromise = null;
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
reinit.then(clearIfCurrent, clearIfCurrent);
|
|
1136
|
+
return reinit;
|
|
1121
1137
|
}
|
|
1122
1138
|
return registration.instance;
|
|
1123
1139
|
}
|
|
@@ -1137,7 +1153,21 @@ var init_service_registry = __esm({
|
|
|
1137
1153
|
}
|
|
1138
1154
|
}
|
|
1139
1155
|
/**
|
|
1140
|
-
* Get a service instance synchronously (returns null if not
|
|
1156
|
+
* Get a service instance synchronously (returns null if not registered or not
|
|
1157
|
+
* yet constructed).
|
|
1158
|
+
*
|
|
1159
|
+
* Deliberately returns instances in NON-settled lifecycles too. This is a
|
|
1160
|
+
* lifecycle *inspector* as much as an accessor: the health check resolves the
|
|
1161
|
+
* knowledge store through it precisely so it can report `initializing` /
|
|
1162
|
+
* `disabled` / `disposed (not running)` WITHOUT forcing initialization (forcing
|
|
1163
|
+
* it was a past cause of 45 s CI stalls). Filtering those states out here would
|
|
1164
|
+
* make every one of them indistinguishable from "absent".
|
|
1165
|
+
*
|
|
1166
|
+
* The trade-off: because `_initializeService` publishes the instance before
|
|
1167
|
+
* awaiting its `initialize()` (to break dependency cycles), a caller that
|
|
1168
|
+
* immediately *uses* the result can catch a half-built service. Callers that
|
|
1169
|
+
* need a ready service must use the async `get()`, which waits — see the note
|
|
1170
|
+
* there. Callers that only inspect `lifecycle` are safe by construction.
|
|
1141
1171
|
*/
|
|
1142
1172
|
tryGet(name) {
|
|
1143
1173
|
if (this.isDisposing) {
|
|
@@ -1171,6 +1201,10 @@ var init_service_registry = __esm({
|
|
|
1171
1201
|
if (!registration) {
|
|
1172
1202
|
throw new Error(`Service '${name}' is not registered`);
|
|
1173
1203
|
}
|
|
1204
|
+
if (registration.initializationPromise) {
|
|
1205
|
+
await registration.initializationPromise.catch(() => {
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1174
1208
|
if (registration.instance && registration.instance.dispose) {
|
|
1175
1209
|
await registration.instance.dispose().catch((err) => {
|
|
1176
1210
|
logger.warn(`[ServiceContainer] Error disposing service '${name}':`, err);
|
|
@@ -1190,6 +1224,10 @@ var init_service_registry = __esm({
|
|
|
1190
1224
|
if (!registration) {
|
|
1191
1225
|
throw new Error(`Service '${name}' is not registered`);
|
|
1192
1226
|
}
|
|
1227
|
+
if (registration.initializationPromise) {
|
|
1228
|
+
await registration.initializationPromise.catch(() => {
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1193
1231
|
if (registration.instance && registration.instance.dispose) {
|
|
1194
1232
|
await registration.instance.dispose().catch((err) => {
|
|
1195
1233
|
logger.warn(`[ServiceContainer] Error disposing old service '${name}':`, err);
|
|
@@ -1219,6 +1257,24 @@ var init_service_registry = __esm({
|
|
|
1219
1257
|
logger.log("[ServiceContainer] Disposing all services (DAG-ordered teardown)...");
|
|
1220
1258
|
}
|
|
1221
1259
|
try {
|
|
1260
|
+
const inFlight2 = Array.from(this.services.values()).map((r) => r.initializationPromise).filter((p) => p != null);
|
|
1261
|
+
if (inFlight2.length > 0) {
|
|
1262
|
+
if (this.defaultOptions.enableLogging) {
|
|
1263
|
+
logger.debug(`[ServiceContainer] Waiting for ${inFlight2.length} in-flight initialization(s) before teardown`);
|
|
1264
|
+
}
|
|
1265
|
+
let settleTimer;
|
|
1266
|
+
await Promise.race([
|
|
1267
|
+
Promise.allSettled(inFlight2),
|
|
1268
|
+
new Promise((resolve8) => {
|
|
1269
|
+
settleTimer = setTimeout(() => {
|
|
1270
|
+
logger.warn("[ServiceContainer] Timed out waiting for in-flight service initialization; proceeding with teardown");
|
|
1271
|
+
resolve8();
|
|
1272
|
+
}, DISPOSE_INFLIGHT_INIT_TIMEOUT_MS);
|
|
1273
|
+
settleTimer.unref?.();
|
|
1274
|
+
})
|
|
1275
|
+
]);
|
|
1276
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
1277
|
+
}
|
|
1222
1278
|
const activeServices = Array.from(this.services.keys()).filter(
|
|
1223
1279
|
(name) => this.services.get(name)?.instance !== null
|
|
1224
1280
|
);
|
|
@@ -1294,12 +1350,12 @@ var init_service_registry = __esm({
|
|
|
1294
1350
|
if (!registration) {
|
|
1295
1351
|
return null;
|
|
1296
1352
|
}
|
|
1297
|
-
if (registration.instance) {
|
|
1298
|
-
return "initialized" /* INITIALIZED */;
|
|
1299
|
-
}
|
|
1300
1353
|
if (registration.initializationPromise) {
|
|
1301
1354
|
return "initializing" /* INITIALIZING */;
|
|
1302
1355
|
}
|
|
1356
|
+
if (registration.instance) {
|
|
1357
|
+
return "initialized" /* INITIALIZED */;
|
|
1358
|
+
}
|
|
1303
1359
|
return "uninitialized" /* UNINITIALIZED */;
|
|
1304
1360
|
}
|
|
1305
1361
|
/**
|
|
@@ -1709,9 +1765,23 @@ var init_metrics = __esm({
|
|
|
1709
1765
|
});
|
|
1710
1766
|
|
|
1711
1767
|
// src/utils/llm-usage.ts
|
|
1768
|
+
function warnIfUnpriced(model, tokens, cost) {
|
|
1769
|
+
if (tokens <= 0 || cost > 0) return;
|
|
1770
|
+
const m = model;
|
|
1771
|
+
const key = `${m.provider ?? "unknown"}/${m.id ?? "unknown"}`;
|
|
1772
|
+
if (unpricedModelsWarned.has(key)) return;
|
|
1773
|
+
const table = m.cost;
|
|
1774
|
+
const allZero = !table || Object.values(table).every((v) => typeof v !== "number" || v === 0);
|
|
1775
|
+
if (!allZero) return;
|
|
1776
|
+
unpricedModelsWarned.add(key);
|
|
1777
|
+
logger.warn(
|
|
1778
|
+
`[LlmUsage] Model "${key}" consumed ${tokens} tokens but reports $0.00 \u2014 its price table is all zeros. Expected for flat-rate plans and local servers. Otherwise the price data is missing: a hand-written "models" entry in ~/.pi/agent/models.json replaces pi's catalog entry (and its pricing) for the same model id \u2014 remove it and let pi's catalog supply the model, or use "modelOverrides", which merges.`
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1712
1781
|
function recordLlmUsage(model, rawUsage, opts) {
|
|
1713
1782
|
if (!rawUsage) return { tokens: 0, cost: 0 };
|
|
1714
1783
|
const { tokens, cost } = extractUsage(model, rawUsage);
|
|
1784
|
+
warnIfUnpriced(model, tokens, cost);
|
|
1715
1785
|
if (tokens > 0 || cost > 0) {
|
|
1716
1786
|
const labels = { component: opts.component };
|
|
1717
1787
|
if (opts.complexity !== void 0) labels["complexity"] = String(opts.complexity);
|
|
@@ -1721,11 +1791,14 @@ function recordLlmUsage(model, rawUsage, opts) {
|
|
|
1721
1791
|
}
|
|
1722
1792
|
return { tokens, cost };
|
|
1723
1793
|
}
|
|
1794
|
+
var unpricedModelsWarned;
|
|
1724
1795
|
var init_llm_usage = __esm({
|
|
1725
1796
|
"src/utils/llm-usage.ts"() {
|
|
1726
1797
|
"use strict";
|
|
1727
1798
|
init_llm();
|
|
1799
|
+
init_logger();
|
|
1728
1800
|
init_metrics();
|
|
1801
|
+
unpricedModelsWarned = /* @__PURE__ */ new Set();
|
|
1729
1802
|
}
|
|
1730
1803
|
});
|
|
1731
1804
|
|
|
@@ -1739,11 +1812,11 @@ function stripTrailingLlmPunctuation(input) {
|
|
|
1739
1812
|
continue;
|
|
1740
1813
|
}
|
|
1741
1814
|
if (last === ")" || last === "]" || last === "}") {
|
|
1742
|
-
const
|
|
1815
|
+
const open5 = last === ")" ? "(" : last === "]" ? "[" : "{";
|
|
1743
1816
|
let opens = 0;
|
|
1744
1817
|
let closes = 0;
|
|
1745
1818
|
for (const ch of s) {
|
|
1746
|
-
if (ch ===
|
|
1819
|
+
if (ch === open5) opens++;
|
|
1747
1820
|
else if (ch === last) closes++;
|
|
1748
1821
|
}
|
|
1749
1822
|
if (closes > opens) {
|
|
@@ -2026,8 +2099,69 @@ var init_host_config = __esm({
|
|
|
2026
2099
|
}
|
|
2027
2100
|
});
|
|
2028
2101
|
|
|
2102
|
+
// src/utils/atomic-replace.ts
|
|
2103
|
+
import * as fs4 from "node:fs/promises";
|
|
2104
|
+
import * as fsSync from "node:fs";
|
|
2105
|
+
function isRetryable(err) {
|
|
2106
|
+
const code = err?.code;
|
|
2107
|
+
return code !== void 0 && RETRYABLE_CODES.has(code);
|
|
2108
|
+
}
|
|
2109
|
+
async function replaceFile(tmpPath, destPath) {
|
|
2110
|
+
let lastError;
|
|
2111
|
+
for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
|
|
2112
|
+
try {
|
|
2113
|
+
await fs4.rename(tmpPath, destPath);
|
|
2114
|
+
return "renamed";
|
|
2115
|
+
} catch (err) {
|
|
2116
|
+
lastError = err;
|
|
2117
|
+
if (process.platform !== "win32" || !isRetryable(err)) throw err;
|
|
2118
|
+
if (attempt < MAX_RENAME_ATTEMPTS - 1) {
|
|
2119
|
+
await new Promise((r) => setTimeout(r, RENAME_RETRY_DELAY_MS * (attempt + 1)));
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
try {
|
|
2124
|
+
await fs4.copyFile(tmpPath, destPath);
|
|
2125
|
+
return "copied";
|
|
2126
|
+
} catch {
|
|
2127
|
+
throw lastError;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
function replaceFileSync(tmpPath, destPath) {
|
|
2131
|
+
let lastError;
|
|
2132
|
+
for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
|
|
2133
|
+
try {
|
|
2134
|
+
fsSync.renameSync(tmpPath, destPath);
|
|
2135
|
+
return "renamed";
|
|
2136
|
+
} catch (err) {
|
|
2137
|
+
lastError = err;
|
|
2138
|
+
if (process.platform !== "win32" || !isRetryable(err)) throw err;
|
|
2139
|
+
if (attempt < MAX_RENAME_ATTEMPTS - 1) {
|
|
2140
|
+
const until = Date.now() + RENAME_RETRY_DELAY_MS * (attempt + 1);
|
|
2141
|
+
while (Date.now() < until) {
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
try {
|
|
2147
|
+
fsSync.copyFileSync(tmpPath, destPath);
|
|
2148
|
+
return "copied";
|
|
2149
|
+
} catch {
|
|
2150
|
+
throw lastError;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
var RETRYABLE_CODES, MAX_RENAME_ATTEMPTS, RENAME_RETRY_DELAY_MS;
|
|
2154
|
+
var init_atomic_replace = __esm({
|
|
2155
|
+
"src/utils/atomic-replace.ts"() {
|
|
2156
|
+
"use strict";
|
|
2157
|
+
RETRYABLE_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
2158
|
+
MAX_RENAME_ATTEMPTS = 5;
|
|
2159
|
+
RENAME_RETRY_DELAY_MS = 25;
|
|
2160
|
+
}
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2029
2163
|
// src/config.ts
|
|
2030
|
-
import * as
|
|
2164
|
+
import * as fs5 from "node:fs";
|
|
2031
2165
|
import * as path5 from "node:path";
|
|
2032
2166
|
import * as os2 from "node:os";
|
|
2033
2167
|
import { Type } from "typebox";
|
|
@@ -2042,8 +2176,8 @@ function getProjectSettingsRegistryPath() {
|
|
|
2042
2176
|
function loadProjectSettingsRegistry() {
|
|
2043
2177
|
const registryPath = getProjectSettingsRegistryPath();
|
|
2044
2178
|
try {
|
|
2045
|
-
if (
|
|
2046
|
-
const content =
|
|
2179
|
+
if (fs5.existsSync(registryPath)) {
|
|
2180
|
+
const content = fs5.readFileSync(registryPath, "utf-8");
|
|
2047
2181
|
return JSON.parse(content);
|
|
2048
2182
|
}
|
|
2049
2183
|
} catch (err) {
|
|
@@ -2064,8 +2198,8 @@ function sleepSync(ms) {
|
|
|
2064
2198
|
function updateProjectSettingsRegistry(mutate) {
|
|
2065
2199
|
const registryPath = getProjectSettingsRegistryPath();
|
|
2066
2200
|
const dir = path5.dirname(registryPath);
|
|
2067
|
-
if (!
|
|
2068
|
-
|
|
2201
|
+
if (!fs5.existsSync(dir)) {
|
|
2202
|
+
fs5.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2069
2203
|
}
|
|
2070
2204
|
const lockPath = `${registryPath}.lock`;
|
|
2071
2205
|
let lockFd = null;
|
|
@@ -2073,14 +2207,14 @@ function updateProjectSettingsRegistry(mutate) {
|
|
|
2073
2207
|
try {
|
|
2074
2208
|
for (let i = 0; i < maxRetries; i++) {
|
|
2075
2209
|
try {
|
|
2076
|
-
lockFd =
|
|
2210
|
+
lockFd = fs5.openSync(lockPath, "wx", 384);
|
|
2077
2211
|
break;
|
|
2078
2212
|
} catch (err) {
|
|
2079
2213
|
if (err.code === "EEXIST") {
|
|
2080
2214
|
try {
|
|
2081
|
-
const stats =
|
|
2215
|
+
const stats = fs5.statSync(lockPath);
|
|
2082
2216
|
if (Date.now() - stats.mtimeMs > 3e4) {
|
|
2083
|
-
|
|
2217
|
+
fs5.unlinkSync(lockPath);
|
|
2084
2218
|
continue;
|
|
2085
2219
|
}
|
|
2086
2220
|
} catch {
|
|
@@ -2095,9 +2229,9 @@ function updateProjectSettingsRegistry(mutate) {
|
|
|
2095
2229
|
throw new Error(`Failed to acquire lock for project settings registry after ${maxRetries} retries. Aborting to prevent data corruption.`);
|
|
2096
2230
|
}
|
|
2097
2231
|
let registry = {};
|
|
2098
|
-
if (
|
|
2232
|
+
if (fs5.existsSync(registryPath)) {
|
|
2099
2233
|
try {
|
|
2100
|
-
registry = JSON.parse(
|
|
2234
|
+
registry = JSON.parse(fs5.readFileSync(registryPath, "utf-8"));
|
|
2101
2235
|
} catch (readErr) {
|
|
2102
2236
|
if (readErr?.code === "ENOENT") {
|
|
2103
2237
|
registry = {};
|
|
@@ -2112,15 +2246,15 @@ function updateProjectSettingsRegistry(mutate) {
|
|
|
2112
2246
|
mutate(registry);
|
|
2113
2247
|
const tmpPath = `${registryPath}.tmp.${process.pid}.${Date.now()}`;
|
|
2114
2248
|
try {
|
|
2115
|
-
|
|
2116
|
-
|
|
2249
|
+
fs5.writeFileSync(tmpPath, JSON.stringify(registry, null, 2), { encoding: "utf-8", mode: 384 });
|
|
2250
|
+
fs5.renameSync(tmpPath, registryPath);
|
|
2117
2251
|
try {
|
|
2118
|
-
|
|
2252
|
+
fs5.chmodSync(registryPath, 384);
|
|
2119
2253
|
} catch {
|
|
2120
2254
|
}
|
|
2121
2255
|
} catch (writeErr) {
|
|
2122
2256
|
try {
|
|
2123
|
-
|
|
2257
|
+
fs5.unlinkSync(tmpPath);
|
|
2124
2258
|
} catch {
|
|
2125
2259
|
}
|
|
2126
2260
|
throw writeErr;
|
|
@@ -2131,11 +2265,11 @@ function updateProjectSettingsRegistry(mutate) {
|
|
|
2131
2265
|
} finally {
|
|
2132
2266
|
if (lockFd !== null) {
|
|
2133
2267
|
try {
|
|
2134
|
-
|
|
2268
|
+
fs5.closeSync(lockFd);
|
|
2135
2269
|
} catch {
|
|
2136
2270
|
}
|
|
2137
2271
|
try {
|
|
2138
|
-
|
|
2272
|
+
fs5.unlinkSync(lockPath);
|
|
2139
2273
|
} catch {
|
|
2140
2274
|
}
|
|
2141
2275
|
}
|
|
@@ -2165,7 +2299,7 @@ function describeKnowledgeStoreMode(cwd = process.cwd(), iface) {
|
|
|
2165
2299
|
const KEY = "PI_RESEARCH_KNOWLEDGE_STORE_MODE";
|
|
2166
2300
|
const fileHasKey = (p) => {
|
|
2167
2301
|
try {
|
|
2168
|
-
return
|
|
2302
|
+
return fs5.existsSync(p) && parseDotEnv(fs5.readFileSync(p, "utf-8"))[KEY] !== void 0;
|
|
2169
2303
|
} catch {
|
|
2170
2304
|
return false;
|
|
2171
2305
|
}
|
|
@@ -2177,7 +2311,7 @@ function describeKnowledgeStoreMode(cwd = process.cwd(), iface) {
|
|
|
2177
2311
|
let registryHasKey = false;
|
|
2178
2312
|
try {
|
|
2179
2313
|
const registry = loadProjectSettingsRegistry();
|
|
2180
|
-
registryHasKey = registry
|
|
2314
|
+
registryHasKey = findRegistryEntry(registry, normalizeWorkspacePath(cwd))?.[KEY] !== void 0;
|
|
2181
2315
|
} catch {
|
|
2182
2316
|
}
|
|
2183
2317
|
if (registryHasKey) origin = "this directory (project settings)";
|
|
@@ -2203,14 +2337,27 @@ function parseDotEnv(content) {
|
|
|
2203
2337
|
}
|
|
2204
2338
|
return out;
|
|
2205
2339
|
}
|
|
2340
|
+
function findRegistryKey(registry, normalizedCwd) {
|
|
2341
|
+
if (registry[normalizedCwd] !== void 0) return normalizedCwd;
|
|
2342
|
+
if (process.platform !== "win32" && process.platform !== "darwin") return void 0;
|
|
2343
|
+
const lowered = normalizedCwd.toLowerCase();
|
|
2344
|
+
for (const key of Object.keys(registry)) {
|
|
2345
|
+
if (key.toLowerCase() === lowered) return key;
|
|
2346
|
+
}
|
|
2347
|
+
return void 0;
|
|
2348
|
+
}
|
|
2349
|
+
function findRegistryEntry(registry, normalizedCwd) {
|
|
2350
|
+
const key = findRegistryKey(registry, normalizedCwd);
|
|
2351
|
+
return key === void 0 ? void 0 : registry[key];
|
|
2352
|
+
}
|
|
2206
2353
|
function loadEnvFiles(cwd, iface) {
|
|
2207
2354
|
const merged = {};
|
|
2208
2355
|
const globalPath = getGlobalEnvFilePath();
|
|
2209
2356
|
const registry = loadProjectSettingsRegistry();
|
|
2210
2357
|
const normalizedCwd = normalizeWorkspacePath(cwd);
|
|
2211
|
-
const homeRegistry = registry
|
|
2358
|
+
const homeRegistry = findRegistryEntry(registry, normalizeWorkspacePath(os2.homedir()));
|
|
2212
2359
|
try {
|
|
2213
|
-
if (!
|
|
2360
|
+
if (!fs5.existsSync(globalPath) && homeRegistry && Object.keys(homeRegistry).length > 0) {
|
|
2214
2361
|
logger.info("[config] config.env missing. Initializing from user settings in central registry...");
|
|
2215
2362
|
const migrated = {};
|
|
2216
2363
|
for (const key of USER_MIGRATION_KEYS) {
|
|
@@ -2225,8 +2372,8 @@ function loadEnvFiles(cwd, iface) {
|
|
|
2225
2372
|
logger.warn("[config] Failed one-time config.env migration:", err);
|
|
2226
2373
|
}
|
|
2227
2374
|
try {
|
|
2228
|
-
if (
|
|
2229
|
-
Object.assign(merged, parseDotEnv(
|
|
2375
|
+
if (fs5.existsSync(globalPath)) {
|
|
2376
|
+
Object.assign(merged, parseDotEnv(fs5.readFileSync(globalPath, "utf-8")));
|
|
2230
2377
|
}
|
|
2231
2378
|
} catch (err) {
|
|
2232
2379
|
logger.warn("[config] Failed to read global env file:", err);
|
|
@@ -2234,8 +2381,8 @@ function loadEnvFiles(cwd, iface) {
|
|
|
2234
2381
|
if (iface) {
|
|
2235
2382
|
const ifacePath = getInterfaceEnvFilePath(iface);
|
|
2236
2383
|
try {
|
|
2237
|
-
if (
|
|
2238
|
-
Object.assign(merged, parseDotEnv(
|
|
2384
|
+
if (fs5.existsSync(ifacePath)) {
|
|
2385
|
+
Object.assign(merged, parseDotEnv(fs5.readFileSync(ifacePath, "utf-8")));
|
|
2239
2386
|
}
|
|
2240
2387
|
} catch (err) {
|
|
2241
2388
|
logger.warn(`[config] Failed to read interface config ${ifacePath}:`, err);
|
|
@@ -2243,30 +2390,33 @@ function loadEnvFiles(cwd, iface) {
|
|
|
2243
2390
|
}
|
|
2244
2391
|
const legacyPath = getLocalEnvFilePath(cwd);
|
|
2245
2392
|
let legacyEnv = {};
|
|
2246
|
-
if (
|
|
2393
|
+
if (fs5.existsSync(legacyPath)) {
|
|
2247
2394
|
try {
|
|
2248
2395
|
logger.warn(`[config] WARNING: .pi-research.env files are deprecated. Settings will be auto-migrated to the centralized registry. Remove ${legacyPath} after migration.`);
|
|
2249
|
-
legacyEnv = parseDotEnv(
|
|
2396
|
+
legacyEnv = parseDotEnv(fs5.readFileSync(legacyPath, "utf-8"));
|
|
2250
2397
|
Object.assign(merged, legacyEnv);
|
|
2251
2398
|
const legacyLocal = Object.fromEntries(
|
|
2252
2399
|
Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
|
|
2253
2400
|
);
|
|
2254
2401
|
const existingLocal = Object.fromEntries(
|
|
2255
|
-
Object.entries(registry
|
|
2402
|
+
Object.entries(findRegistryEntry(registry, normalizedCwd) ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
|
|
2256
2403
|
);
|
|
2257
2404
|
if (Object.keys(legacyLocal).length > 0 && JSON.stringify(existingLocal) !== JSON.stringify(legacyLocal)) {
|
|
2258
2405
|
logger.info(`[config] Migrating legacy .pi-research.env settings from ${cwd} to central registry...`);
|
|
2259
|
-
|
|
2406
|
+
const mergeKey = findRegistryKey(registry, normalizedCwd) ?? normalizedCwd;
|
|
2407
|
+
registry[mergeKey] = { ...registry[mergeKey], ...legacyLocal };
|
|
2260
2408
|
updateProjectSettingsRegistry((disk) => {
|
|
2261
|
-
|
|
2409
|
+
const diskKey = findRegistryKey(disk, normalizedCwd) ?? normalizedCwd;
|
|
2410
|
+
disk[diskKey] = { ...disk[diskKey], ...legacyLocal };
|
|
2262
2411
|
});
|
|
2263
2412
|
}
|
|
2264
2413
|
} catch (err) {
|
|
2265
2414
|
logger.warn(`[config] Failed to load legacy settings for ${cwd}:`, err);
|
|
2266
2415
|
}
|
|
2267
2416
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2417
|
+
const cwdEntry = findRegistryEntry(registry, normalizedCwd);
|
|
2418
|
+
if (cwdEntry) {
|
|
2419
|
+
for (const [key, val] of Object.entries(cwdEntry)) {
|
|
2270
2420
|
if (LOCAL_SCOPE_KEYS.has(key)) {
|
|
2271
2421
|
if (legacyEnv[key] !== void 0 && legacyEnv[key] !== val) {
|
|
2272
2422
|
logger.warn(`[config] Config divergence for ${key} in ${cwd}: registry value differs from legacy .pi-research.env. Registry wins.`);
|
|
@@ -2276,7 +2426,7 @@ function loadEnvFiles(cwd, iface) {
|
|
|
2276
2426
|
logger.warn(`[config] Ignoring stale user-scoped ${key} in the project registry (config.env wins). Re-save your user settings to drop it.`);
|
|
2277
2427
|
}
|
|
2278
2428
|
}
|
|
2279
|
-
} else if (Object.keys(merged).length === 0 && !
|
|
2429
|
+
} else if (Object.keys(merged).length === 0 && !fs5.existsSync(legacyPath) && !fs5.existsSync(globalPath)) {
|
|
2280
2430
|
logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
|
|
2281
2431
|
}
|
|
2282
2432
|
warnLegacyEnvVarsOnce(merged);
|
|
@@ -2340,7 +2490,8 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
|
|
|
2340
2490
|
if (scope === "local") {
|
|
2341
2491
|
const normalizedCwd = normalizeWorkspacePath(cwd);
|
|
2342
2492
|
updateProjectSettingsRegistry((registry) => {
|
|
2343
|
-
|
|
2493
|
+
const key = findRegistryKey(registry, normalizedCwd) ?? normalizedCwd;
|
|
2494
|
+
registry[key] = { ...registry[key] ?? {}, ...newValues };
|
|
2344
2495
|
});
|
|
2345
2496
|
logger.debug(`[config] Saved project settings for ${normalizedCwd} to central registry.`);
|
|
2346
2497
|
return;
|
|
@@ -2348,22 +2499,22 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
|
|
|
2348
2499
|
const p = getGlobalEnvFilePath();
|
|
2349
2500
|
const lockPath = `${p}.lock`;
|
|
2350
2501
|
const dir = path5.dirname(p);
|
|
2351
|
-
if (!
|
|
2352
|
-
|
|
2502
|
+
if (!fs5.existsSync(dir)) {
|
|
2503
|
+
fs5.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2353
2504
|
}
|
|
2354
2505
|
let lockFd = null;
|
|
2355
2506
|
const lockRetryDelay = 50;
|
|
2356
2507
|
const lockMaxRetries = 100;
|
|
2357
2508
|
for (let i = 0; i < lockMaxRetries; i++) {
|
|
2358
2509
|
try {
|
|
2359
|
-
lockFd =
|
|
2510
|
+
lockFd = fs5.openSync(lockPath, "wx", 384);
|
|
2360
2511
|
break;
|
|
2361
2512
|
} catch (err) {
|
|
2362
2513
|
if (err.code === "EEXIST") {
|
|
2363
2514
|
try {
|
|
2364
|
-
const stats =
|
|
2515
|
+
const stats = fs5.statSync(lockPath);
|
|
2365
2516
|
if (Date.now() - stats.mtimeMs > 3e4) {
|
|
2366
|
-
|
|
2517
|
+
fs5.unlinkSync(lockPath);
|
|
2367
2518
|
continue;
|
|
2368
2519
|
}
|
|
2369
2520
|
} catch {
|
|
@@ -2380,8 +2531,8 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
|
|
|
2380
2531
|
}
|
|
2381
2532
|
try {
|
|
2382
2533
|
let lines = [];
|
|
2383
|
-
if (
|
|
2384
|
-
lines =
|
|
2534
|
+
if (fs5.existsSync(p)) {
|
|
2535
|
+
lines = fs5.readFileSync(p, "utf-8").split("\n");
|
|
2385
2536
|
} else {
|
|
2386
2537
|
lines = [
|
|
2387
2538
|
`# pi-research global configuration`,
|
|
@@ -2420,35 +2571,32 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
|
|
|
2420
2571
|
}
|
|
2421
2572
|
}
|
|
2422
2573
|
const tmpPath = `${p}.tmp.${process.pid}.${Date.now()}`;
|
|
2423
|
-
|
|
2574
|
+
fs5.writeFileSync(tmpPath, outLines.join("\n"), { encoding: "utf-8", mode: 384 });
|
|
2424
2575
|
try {
|
|
2425
|
-
|
|
2426
|
-
} catch (renameErr) {
|
|
2427
|
-
if (process.platform === "win32") {
|
|
2428
|
-
fs4.copyFileSync(tmpPath, p);
|
|
2576
|
+
if (replaceFileSync(tmpPath, p) === "copied") {
|
|
2429
2577
|
try {
|
|
2430
|
-
|
|
2578
|
+
fs5.unlinkSync(tmpPath);
|
|
2431
2579
|
} catch {
|
|
2432
2580
|
}
|
|
2433
|
-
} else {
|
|
2434
|
-
try {
|
|
2435
|
-
fs4.unlinkSync(tmpPath);
|
|
2436
|
-
} catch {
|
|
2437
|
-
}
|
|
2438
|
-
throw renameErr;
|
|
2439
2581
|
}
|
|
2582
|
+
} catch (renameErr) {
|
|
2583
|
+
try {
|
|
2584
|
+
fs5.unlinkSync(tmpPath);
|
|
2585
|
+
} catch {
|
|
2586
|
+
}
|
|
2587
|
+
throw renameErr;
|
|
2440
2588
|
}
|
|
2441
2589
|
try {
|
|
2442
|
-
|
|
2590
|
+
fs5.chmodSync(p, 384);
|
|
2443
2591
|
} catch {
|
|
2444
2592
|
}
|
|
2445
2593
|
} catch (err) {
|
|
2446
2594
|
logger.error(`[config] Failed to write config to ${p}:`, err);
|
|
2447
2595
|
throw err;
|
|
2448
2596
|
} finally {
|
|
2449
|
-
|
|
2597
|
+
fs5.closeSync(lockFd);
|
|
2450
2598
|
try {
|
|
2451
|
-
|
|
2599
|
+
fs5.unlinkSync(lockPath);
|
|
2452
2600
|
} catch {
|
|
2453
2601
|
}
|
|
2454
2602
|
}
|
|
@@ -2575,6 +2723,7 @@ var init_config = __esm({
|
|
|
2575
2723
|
init_logger();
|
|
2576
2724
|
init_host_config();
|
|
2577
2725
|
init_text_utils();
|
|
2726
|
+
init_atomic_replace();
|
|
2578
2727
|
ConfigSchema = Type.Object({
|
|
2579
2728
|
/** Per-researcher timeout in milliseconds (default: 300000, range: 3-30 min) */
|
|
2580
2729
|
RESEARCHER_TIMEOUT_MS: Type.Number({ minimum: 18e4, maximum: 18e5, default: 3e5 }),
|
|
@@ -2794,7 +2943,11 @@ function getMaxScrapeBatches(config) {
|
|
|
2794
2943
|
return 2;
|
|
2795
2944
|
}
|
|
2796
2945
|
}
|
|
2797
|
-
|
|
2946
|
+
function resolveExcludedTools(callerExclusions, configDisabled = []) {
|
|
2947
|
+
const base = callerExclusions && callerExclusions.length > 0 ? callerExclusions : DEFAULT_EXCLUDED_TOOLS;
|
|
2948
|
+
return [.../* @__PURE__ */ new Set([...base, ...configDisabled])];
|
|
2949
|
+
}
|
|
2950
|
+
var REQUEST_DELAY_MS_NVD, REQUEST_DELAY_MS_OTHER, MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS;
|
|
2798
2951
|
var init_constants = __esm({
|
|
2799
2952
|
"src/constants.ts"() {
|
|
2800
2953
|
"use strict";
|
|
@@ -2827,6 +2980,7 @@ var init_constants = __esm({
|
|
|
2827
2980
|
MAX_QUERIES_PER_RESEARCHER_LEVEL_1 = 10;
|
|
2828
2981
|
MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
|
|
2829
2982
|
MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
|
|
2983
|
+
DEFAULT_EXCLUDED_TOOLS = ["grep"];
|
|
2830
2984
|
}
|
|
2831
2985
|
});
|
|
2832
2986
|
|
|
@@ -2853,8 +3007,8 @@ function findMatchingBracket(text, start) {
|
|
|
2853
3007
|
let depth = 0;
|
|
2854
3008
|
let inString = false;
|
|
2855
3009
|
let escaped = false;
|
|
2856
|
-
const
|
|
2857
|
-
const close =
|
|
3010
|
+
const open5 = text[start];
|
|
3011
|
+
const close = open5 === "{" ? "}" : "]";
|
|
2858
3012
|
for (let i = start; i < text.length; i++) {
|
|
2859
3013
|
const ch = text[i];
|
|
2860
3014
|
if (escaped) {
|
|
@@ -2870,7 +3024,7 @@ function findMatchingBracket(text, start) {
|
|
|
2870
3024
|
continue;
|
|
2871
3025
|
}
|
|
2872
3026
|
if (inString) continue;
|
|
2873
|
-
if (ch ===
|
|
3027
|
+
if (ch === open5) depth++;
|
|
2874
3028
|
else if (ch === close) {
|
|
2875
3029
|
depth--;
|
|
2876
3030
|
if (depth === 0) return { index: i, inString: false };
|
|
@@ -3210,7 +3364,7 @@ var init_agentic_repair = __esm({
|
|
|
3210
3364
|
import { ModelRegistry, ModelRuntime, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3211
3365
|
import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
|
|
3212
3366
|
import * as path6 from "node:path";
|
|
3213
|
-
import * as
|
|
3367
|
+
import * as fs6 from "node:fs";
|
|
3214
3368
|
function safeGetAll(registry) {
|
|
3215
3369
|
if (registry && typeof registry.getAll === "function") {
|
|
3216
3370
|
try {
|
|
@@ -3254,8 +3408,8 @@ async function buildModelRegistry(apiKey, provider) {
|
|
|
3254
3408
|
const agentDir = getAgentDir();
|
|
3255
3409
|
const modelsJsonPath = path6.join(agentDir, "models.json");
|
|
3256
3410
|
const authPath = path6.join(agentDir, "auth.json");
|
|
3257
|
-
const hasModels =
|
|
3258
|
-
const hasAuth =
|
|
3411
|
+
const hasModels = fs6.existsSync(modelsJsonPath);
|
|
3412
|
+
const hasAuth = fs6.existsSync(authPath);
|
|
3259
3413
|
const runtime = await ModelRuntime.create({
|
|
3260
3414
|
authPath: hasAuth ? authPath : void 0,
|
|
3261
3415
|
credentials: hasAuth ? void 0 : new InMemoryCredentialStore(),
|
|
@@ -3292,6 +3446,13 @@ function constructMinimalModel(provider, modelId, _apiKey) {
|
|
|
3292
3446
|
// Provider-specific base URLs are handled by pi-ai internal registry
|
|
3293
3447
|
reasoning: false,
|
|
3294
3448
|
input: ["text"],
|
|
3449
|
+
// No catalog is available on this path, so there is no price data and no real
|
|
3450
|
+
// context/output ceiling to read. The zeros below are "unknown", NOT "free":
|
|
3451
|
+
// cost reporting for this model will read $0.00 (see warnIfUnpriced in
|
|
3452
|
+
// utils/llm-usage.ts), and the window figures are conservative guesses, not the
|
|
3453
|
+
// model's true limits — so docs/CONFIGURATION.md's "clamped to the model's real
|
|
3454
|
+
// ceiling" guarantee does not hold here. Anyone relying on accurate cost or the
|
|
3455
|
+
// full context window should give pi a credential so it can fetch the real catalog.
|
|
3295
3456
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
3296
3457
|
contextWindow: 128e3,
|
|
3297
3458
|
maxTokens: 32768,
|
|
@@ -3309,7 +3470,7 @@ function resolveModel(registry, modelSpec, provider, apiKey) {
|
|
|
3309
3470
|
if (apiKey && provider) {
|
|
3310
3471
|
return constructMinimalModel(prov, modelId, apiKey);
|
|
3311
3472
|
}
|
|
3312
|
-
throw new Error(
|
|
3473
|
+
throw new Error(buildModelNotFoundMessage(modelSpec));
|
|
3313
3474
|
}
|
|
3314
3475
|
const allModels = safeGetAll(registry);
|
|
3315
3476
|
const sameId = allModels.filter((m) => m.id === modelSpec);
|
|
@@ -3340,15 +3501,29 @@ function resolveModel(registry, modelSpec, provider, apiKey) {
|
|
|
3340
3501
|
}
|
|
3341
3502
|
return constructMinimalModel(provider, modelSpec, apiKey);
|
|
3342
3503
|
}
|
|
3343
|
-
throw new Error(
|
|
3344
|
-
|
|
3345
|
-
|
|
3504
|
+
throw new Error(buildNoModelAvailableMessage());
|
|
3505
|
+
}
|
|
3506
|
+
function credentialGuidance() {
|
|
3507
|
+
const agentDir = getAgentDir();
|
|
3508
|
+
return ` \u2022 Provider pi supports built-in (openrouter, anthropic, openai, \u2026): add a credential \u2014 run \`pi\` and use /login, set the provider's API-key environment variable, or add it to ${path6.join(agentDir, "auth.json")}. pi then fetches that provider's model catalog automatically.
|
|
3509
|
+
\u2022 Custom or self-hosted provider (Ollama, vLLM, a proxy): declare it in ${path6.join(agentDir, "models.json")}.
|
|
3510
|
+
Note: a models.json entry REPLACES pi's catalog entry for the same model id, discarding its pricing and
|
|
3511
|
+
context/output limits. To adjust a single field of a built-in model, use "modelOverrides" instead \u2014 it merges.`;
|
|
3512
|
+
}
|
|
3513
|
+
function buildModelNotFoundMessage(modelSpec) {
|
|
3514
|
+
return `Model "${modelSpec}" not found in pi's model registry.
|
|
3515
|
+
${credentialGuidance()}`;
|
|
3516
|
+
}
|
|
3517
|
+
function buildNoModelAvailableMessage() {
|
|
3518
|
+
return `No LLM model available.
|
|
3519
|
+
${credentialGuidance()}
|
|
3520
|
+
\u2022 Or pass an explicit apiKey.`;
|
|
3346
3521
|
}
|
|
3347
3522
|
function readModelsJsonProviderOrder() {
|
|
3348
3523
|
const modelsJsonPath = path6.join(getAgentDir(), "models.json");
|
|
3349
|
-
if (!
|
|
3524
|
+
if (!fs6.existsSync(modelsJsonPath)) return [];
|
|
3350
3525
|
try {
|
|
3351
|
-
const raw = JSON.parse(
|
|
3526
|
+
const raw = JSON.parse(fs6.readFileSync(modelsJsonPath, "utf-8"));
|
|
3352
3527
|
return Object.keys(raw?.providers ?? {});
|
|
3353
3528
|
} catch (err) {
|
|
3354
3529
|
logger.warn(`[ModelRegistry] Could not read provider order from models.json: ${err}`);
|
|
@@ -3428,6 +3603,7 @@ function withTimeout2(promise, timeoutMs, label, signal) {
|
|
|
3428
3603
|
logger.debug(`[withTimeout] Starting ${timeoutMs}ms timeout guard (external signal: ${signal?.aborted ?? "no signal"})`);
|
|
3429
3604
|
return new Promise((resolve8, reject) => {
|
|
3430
3605
|
if (combinedSignal.aborted) {
|
|
3606
|
+
void promise.catch(() => void 0);
|
|
3431
3607
|
logger.debug(`[withTimeout] ${label} already aborted at start (external cancellation)`);
|
|
3432
3608
|
return reject(new Error(`${label} cancelled or timed out`));
|
|
3433
3609
|
}
|
|
@@ -3533,7 +3709,7 @@ async function retryWithBackoff(fn, options = {}) {
|
|
|
3533
3709
|
metrics.observe("retry_backoff_delay_ms", delay2, { attempt: String(attempt), label: opts.label });
|
|
3534
3710
|
logger.warn(`[Retry] ${opts.label} failed (attempt ${attempt + 1}/${opts.maxRetries + 1}), retrying in ${Math.round(delay2)}ms: ${lastError.message}`);
|
|
3535
3711
|
try {
|
|
3536
|
-
await abortableDelay(delay2, opts.signal);
|
|
3712
|
+
await abortableDelay(delay2, opts.signal, true);
|
|
3537
3713
|
} catch {
|
|
3538
3714
|
metrics.increment("retry_aborted_total", 1, { label: opts.label });
|
|
3539
3715
|
throw lastError;
|
|
@@ -3553,11 +3729,11 @@ var init_retry_utils = __esm({
|
|
|
3553
3729
|
});
|
|
3554
3730
|
|
|
3555
3731
|
// src/infrastructure/file-lock-service.ts
|
|
3556
|
-
import * as
|
|
3732
|
+
import * as fs7 from "node:fs/promises";
|
|
3557
3733
|
import * as crypto from "node:crypto";
|
|
3558
3734
|
import * as pathmod from "node:path";
|
|
3559
3735
|
import { AsyncLocalStorage as AsyncLocalStorage6 } from "node:async_hooks";
|
|
3560
|
-
var lockContext, FileLockService;
|
|
3736
|
+
var lockContext, CLEANUP_DRAIN_TIMEOUT_MS, TRASH_SWEEP_AGE_MS, FileLockService;
|
|
3561
3737
|
var init_file_lock_service = __esm({
|
|
3562
3738
|
"src/infrastructure/file-lock-service.ts"() {
|
|
3563
3739
|
"use strict";
|
|
@@ -3566,6 +3742,8 @@ var init_file_lock_service = __esm({
|
|
|
3566
3742
|
init_service_registry();
|
|
3567
3743
|
init_service_names();
|
|
3568
3744
|
lockContext = new AsyncLocalStorage6();
|
|
3745
|
+
CLEANUP_DRAIN_TIMEOUT_MS = 5e3;
|
|
3746
|
+
TRASH_SWEEP_AGE_MS = 10 * 6e4;
|
|
3569
3747
|
FileLockService = class {
|
|
3570
3748
|
name = ServiceNames.FILE_LOCK_SERVICE;
|
|
3571
3749
|
lifecycle = "uninitialized" /* UNINITIALIZED */;
|
|
@@ -3588,6 +3766,9 @@ var init_file_lock_service = __esm({
|
|
|
3588
3766
|
// PID reuse. Null when unavailable (no processLifecycle, or lookup failed) —
|
|
3589
3767
|
// then lock content omits startTime and readers treat us as a legacy owner.
|
|
3590
3768
|
ownStartTime = null;
|
|
3769
|
+
// Set by cleanup(). A retired instance no longer manages the lock file, so
|
|
3770
|
+
// acquiring through it would hand out exclusivity it cannot honour.
|
|
3771
|
+
_retired = false;
|
|
3591
3772
|
constructor(options) {
|
|
3592
3773
|
this.lockFilePath = options.lockFilePath;
|
|
3593
3774
|
this.lockTimeout = options.lockTimeout ?? 2e4;
|
|
@@ -3635,7 +3816,7 @@ var init_file_lock_service = __esm({
|
|
|
3635
3816
|
this.lifecycle = "initializing" /* INITIALIZING */;
|
|
3636
3817
|
try {
|
|
3637
3818
|
const lockDir = pathmod.dirname(this.lockFilePath);
|
|
3638
|
-
await
|
|
3819
|
+
await fs7.mkdir(lockDir, { recursive: true, mode: 448 });
|
|
3639
3820
|
} catch (err) {
|
|
3640
3821
|
logger.warn(`[FileLockService] Failed to create lock directory: ${err instanceof Error ? err.message : String(err)}`);
|
|
3641
3822
|
}
|
|
@@ -3655,15 +3836,104 @@ var init_file_lock_service = __esm({
|
|
|
3655
3836
|
await this.cleanup();
|
|
3656
3837
|
this.lifecycle = "disposed" /* DISPOSED */;
|
|
3657
3838
|
}
|
|
3839
|
+
/**
|
|
3840
|
+
* Put a lock file back after a trash-and-verify decided not to reclaim it.
|
|
3841
|
+
*
|
|
3842
|
+
* The old form linked it back inside a swallowing catch and then unlinked the
|
|
3843
|
+
* trash copy unconditionally — so any link failure other than "a peer already
|
|
3844
|
+
* re-created it" DESTROYED the lock this branch exists to preserve. Distinguish
|
|
3845
|
+
* the two: if the path is occupied again the peer's lock wins and ours is dropped;
|
|
3846
|
+
* otherwise move ours back rather than deleting it.
|
|
3847
|
+
*/
|
|
3848
|
+
async _restoreLockFile(trashPath) {
|
|
3849
|
+
try {
|
|
3850
|
+
await fs7.link(trashPath, this.lockFilePath);
|
|
3851
|
+
await fs7.unlink(trashPath).catch(() => {
|
|
3852
|
+
});
|
|
3853
|
+
return;
|
|
3854
|
+
} catch {
|
|
3855
|
+
}
|
|
3856
|
+
const peerHoldsIt = await fs7.access(this.lockFilePath).then(() => true).catch(() => false);
|
|
3857
|
+
if (peerHoldsIt) {
|
|
3858
|
+
await fs7.unlink(trashPath).catch(() => {
|
|
3859
|
+
});
|
|
3860
|
+
return;
|
|
3861
|
+
}
|
|
3862
|
+
try {
|
|
3863
|
+
const content = await fs7.readFile(trashPath, "utf-8");
|
|
3864
|
+
const handle = await fs7.open(this.lockFilePath, "wx", 384);
|
|
3865
|
+
try {
|
|
3866
|
+
await handle.write(content);
|
|
3867
|
+
await handle.sync();
|
|
3868
|
+
} finally {
|
|
3869
|
+
await handle.close().catch(() => {
|
|
3870
|
+
});
|
|
3871
|
+
}
|
|
3872
|
+
await fs7.unlink(trashPath).catch(() => {
|
|
3873
|
+
});
|
|
3874
|
+
} catch {
|
|
3875
|
+
const occupied = await fs7.access(this.lockFilePath).then(() => true).catch(() => false);
|
|
3876
|
+
if (occupied) await fs7.unlink(trashPath).catch(() => {
|
|
3877
|
+
});
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
/**
|
|
3881
|
+
* Hand our turn back to the next caller in the FIFO queue.
|
|
3882
|
+
*
|
|
3883
|
+
* Every path that stops holding the lock must run this, including the ones that
|
|
3884
|
+
* find the handle already gone. `acquireLock` parks on `await previous` with no
|
|
3885
|
+
* timeout of its own — the lockTimeout only starts counting once the turn has
|
|
3886
|
+
* been granted — so a turn that is never resolved is not a slow acquire, it is a
|
|
3887
|
+
* permanent one.
|
|
3888
|
+
*/
|
|
3889
|
+
_releaseTurn() {
|
|
3890
|
+
const resolve8 = this.resolveTurn;
|
|
3891
|
+
this.resolveTurn = null;
|
|
3892
|
+
if (resolve8) resolve8();
|
|
3893
|
+
}
|
|
3894
|
+
/**
|
|
3895
|
+
* Wait, bounded, for an in-flight critical section to finish.
|
|
3896
|
+
*
|
|
3897
|
+
* Yanking a held lock is not a clean teardown: unlinking the file while the
|
|
3898
|
+
* holder is still mid read-modify-write drops cross-process exclusion, and a peer
|
|
3899
|
+
* that acquires in that window loses the holder's update — precisely the hazard
|
|
3900
|
+
* the lock exists to prevent. Since every caller of {@link cleanup} is a shutdown
|
|
3901
|
+
* path, waiting briefly for a normal release is both safe and correct.
|
|
3902
|
+
*
|
|
3903
|
+
* Bounded because shutdown must make progress regardless: if the holder is wedged
|
|
3904
|
+
* (or is itself the caller, which would otherwise deadlock), we give up and force
|
|
3905
|
+
* the teardown, which is the pre-existing behaviour.
|
|
3906
|
+
*/
|
|
3907
|
+
async _drainHeldLock(timeoutMs) {
|
|
3908
|
+
const deadline = Date.now() + timeoutMs;
|
|
3909
|
+
while (this.lockHandle !== null && Date.now() < deadline) {
|
|
3910
|
+
await this.sleep(Math.min(25, this.lockRetryDelay));
|
|
3911
|
+
}
|
|
3912
|
+
return this.lockHandle === null;
|
|
3913
|
+
}
|
|
3658
3914
|
/**
|
|
3659
3915
|
* Clean up any stale lock files on initialization.
|
|
3660
3916
|
* This handles cases where locks weren't released due to crashes.
|
|
3661
3917
|
*/
|
|
3662
3918
|
async cleanupStaleLocksOnStartup() {
|
|
3663
3919
|
try {
|
|
3664
|
-
const
|
|
3920
|
+
const dir = pathmod.dirname(this.lockFilePath);
|
|
3921
|
+
const prefix = `${pathmod.basename(this.lockFilePath)}.trash.`;
|
|
3922
|
+
for (const name of await fs7.readdir(dir)) {
|
|
3923
|
+
if (!name.startsWith(prefix)) continue;
|
|
3924
|
+
const p = pathmod.join(dir, name);
|
|
3925
|
+
const st = await fs7.stat(p).catch(() => null);
|
|
3926
|
+
if (st && Date.now() - st.mtimeMs > TRASH_SWEEP_AGE_MS) {
|
|
3927
|
+
await fs7.unlink(p).catch(() => {
|
|
3928
|
+
});
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
} catch {
|
|
3932
|
+
}
|
|
3933
|
+
try {
|
|
3934
|
+
const rawContent = await fs7.readFile(this.lockFilePath, "utf-8").catch(() => null);
|
|
3665
3935
|
if (rawContent === null) return;
|
|
3666
|
-
const stats = await
|
|
3936
|
+
const stats = await fs7.stat(this.lockFilePath);
|
|
3667
3937
|
const lockAge = Date.now() - stats.mtimeMs;
|
|
3668
3938
|
const parsed = this._parseLockContent(rawContent);
|
|
3669
3939
|
const memo = /* @__PURE__ */ new Map();
|
|
@@ -3674,21 +3944,21 @@ var init_file_lock_service = __esm({
|
|
|
3674
3944
|
);
|
|
3675
3945
|
const expectedUuid = parsed?.uuid ?? "";
|
|
3676
3946
|
const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString("hex")}`;
|
|
3947
|
+
let renamed = false;
|
|
3677
3948
|
try {
|
|
3678
|
-
await
|
|
3679
|
-
|
|
3949
|
+
await fs7.rename(this.lockFilePath, trashPath);
|
|
3950
|
+
renamed = true;
|
|
3951
|
+
const trashContent = await fs7.readFile(trashPath, "utf-8");
|
|
3680
3952
|
const trashParsed = this._parseLockContent(trashContent);
|
|
3681
3953
|
if ((trashParsed?.uuid ?? "") !== expectedUuid) {
|
|
3682
|
-
|
|
3683
|
-
await fs6.link(trashPath, this.lockFilePath);
|
|
3684
|
-
} catch {
|
|
3685
|
-
}
|
|
3686
|
-
await fs6.unlink(trashPath);
|
|
3954
|
+
await this._restoreLockFile(trashPath);
|
|
3687
3955
|
return;
|
|
3688
3956
|
}
|
|
3689
|
-
await
|
|
3957
|
+
await fs7.unlink(trashPath).catch(() => {
|
|
3958
|
+
});
|
|
3690
3959
|
logger.log("[FileLockService] Stale lock removed");
|
|
3691
3960
|
} catch {
|
|
3961
|
+
if (renamed) await this._restoreLockFile(trashPath);
|
|
3692
3962
|
}
|
|
3693
3963
|
}
|
|
3694
3964
|
} catch (error) {
|
|
@@ -3706,6 +3976,9 @@ var init_file_lock_service = __esm({
|
|
|
3706
3976
|
* @throws Error if unable to acquire lock within timeout
|
|
3707
3977
|
*/
|
|
3708
3978
|
async acquireLock() {
|
|
3979
|
+
if (this._retired) {
|
|
3980
|
+
throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service has been disposed`);
|
|
3981
|
+
}
|
|
3709
3982
|
const heldLocks = lockContext.getStore();
|
|
3710
3983
|
const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
|
|
3711
3984
|
if (heldLocks?.has(lockKey)) {
|
|
@@ -3729,17 +4002,27 @@ var init_file_lock_service = __esm({
|
|
|
3729
4002
|
if (myResolve) myResolve();
|
|
3730
4003
|
return;
|
|
3731
4004
|
}
|
|
4005
|
+
if (this._retired) {
|
|
4006
|
+
myResolve();
|
|
4007
|
+
throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed while queued`);
|
|
4008
|
+
}
|
|
3732
4009
|
this.resolveTurn = myResolve;
|
|
3733
4010
|
const startTime = Date.now();
|
|
3734
4011
|
let contentionCount = 0;
|
|
3735
4012
|
const contendedOwnerAliveMemo = /* @__PURE__ */ new Map();
|
|
3736
4013
|
try {
|
|
3737
4014
|
for (let _attempt = 0; _attempt < this.lockRetries; _attempt++) {
|
|
4015
|
+
if (this._retired) {
|
|
4016
|
+
throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
|
|
4017
|
+
}
|
|
3738
4018
|
let handle = null;
|
|
3739
4019
|
try {
|
|
3740
|
-
handle = await
|
|
4020
|
+
handle = await fs7.open(this.lockFilePath, "wx", 384);
|
|
3741
4021
|
await handle.write(this._makeLockContent());
|
|
3742
4022
|
await handle.sync();
|
|
4023
|
+
if (this._retired) {
|
|
4024
|
+
throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
|
|
4025
|
+
}
|
|
3743
4026
|
this.lockHandle = handle;
|
|
3744
4027
|
this.lockCount = 1;
|
|
3745
4028
|
if (heldLocks) {
|
|
@@ -3761,7 +4044,7 @@ var init_file_lock_service = __esm({
|
|
|
3761
4044
|
} catch {
|
|
3762
4045
|
}
|
|
3763
4046
|
try {
|
|
3764
|
-
await
|
|
4047
|
+
await fs7.unlink(this.lockFilePath);
|
|
3765
4048
|
} catch {
|
|
3766
4049
|
}
|
|
3767
4050
|
}
|
|
@@ -3770,45 +4053,44 @@ var init_file_lock_service = __esm({
|
|
|
3770
4053
|
if (errnoError.code === "EEXIST") {
|
|
3771
4054
|
contentionCount++;
|
|
3772
4055
|
try {
|
|
3773
|
-
const rawContent = await
|
|
4056
|
+
const rawContent = await fs7.readFile(this.lockFilePath, "utf-8");
|
|
3774
4057
|
const parsed = this._parseLockContent(rawContent);
|
|
3775
4058
|
const lockUuid = parsed?.uuid ?? "";
|
|
3776
|
-
const stats = await
|
|
4059
|
+
const stats = await fs7.stat(this.lockFilePath);
|
|
3777
4060
|
const lockAge = Date.now() - stats.mtimeMs;
|
|
3778
4061
|
if (lockUuid === this.lockUuid) {
|
|
3779
4062
|
try {
|
|
3780
|
-
await
|
|
4063
|
+
await fs7.unlink(this.lockFilePath);
|
|
3781
4064
|
} catch {
|
|
3782
4065
|
}
|
|
3783
4066
|
continue;
|
|
3784
4067
|
}
|
|
3785
4068
|
if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, contendedOwnerAliveMemo)) {
|
|
3786
4069
|
const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString("hex")}`;
|
|
4070
|
+
let renamed = false;
|
|
3787
4071
|
try {
|
|
3788
|
-
await
|
|
3789
|
-
|
|
4072
|
+
await fs7.rename(this.lockFilePath, trashPath);
|
|
4073
|
+
renamed = true;
|
|
4074
|
+
const trashContent = await fs7.readFile(trashPath, "utf-8");
|
|
3790
4075
|
const trashParsed = this._parseLockContent(trashContent);
|
|
3791
4076
|
if ((trashParsed?.uuid ?? "") !== lockUuid) {
|
|
3792
|
-
|
|
3793
|
-
await fs6.link(trashPath, this.lockFilePath);
|
|
3794
|
-
} catch {
|
|
3795
|
-
}
|
|
3796
|
-
await fs6.unlink(trashPath);
|
|
4077
|
+
await this._restoreLockFile(trashPath);
|
|
3797
4078
|
continue;
|
|
3798
4079
|
}
|
|
3799
|
-
await
|
|
4080
|
+
await fs7.unlink(trashPath).catch(() => {
|
|
4081
|
+
});
|
|
3800
4082
|
} catch {
|
|
4083
|
+
if (renamed) await this._restoreLockFile(trashPath);
|
|
3801
4084
|
}
|
|
3802
4085
|
continue;
|
|
3803
4086
|
}
|
|
3804
|
-
} catch (
|
|
3805
|
-
const
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
}
|
|
4087
|
+
} catch (inspectError) {
|
|
4088
|
+
const code = inspectError?.code;
|
|
4089
|
+
if (code === "ENOENT") continue;
|
|
4090
|
+
logger.warn(
|
|
4091
|
+
`[FileLockService] Could not inspect contended lock at ${this.lockFilePath} (${code ?? "unknown"}); backing off rather than reclaiming it.`
|
|
4092
|
+
);
|
|
4093
|
+
metrics.increment("state_lock_inspect_failed_total", 1, { code: code ?? "unknown" });
|
|
3812
4094
|
}
|
|
3813
4095
|
if (Date.now() - startTime >= this.lockTimeout) {
|
|
3814
4096
|
const duration = Date.now() - startTime;
|
|
@@ -3829,10 +4111,7 @@ var init_file_lock_service = __esm({
|
|
|
3829
4111
|
metrics.increment("state_lock_acquire_total", 1, { status: "failed" });
|
|
3830
4112
|
throw new Error(`Failed to acquire lock after ${this.lockRetries} retries`);
|
|
3831
4113
|
} catch (err) {
|
|
3832
|
-
|
|
3833
|
-
this.resolveTurn();
|
|
3834
|
-
this.resolveTurn = null;
|
|
3835
|
-
}
|
|
4114
|
+
this._releaseTurn();
|
|
3836
4115
|
throw err;
|
|
3837
4116
|
}
|
|
3838
4117
|
}
|
|
@@ -3841,6 +4120,7 @@ var init_file_lock_service = __esm({
|
|
|
3841
4120
|
*/
|
|
3842
4121
|
async releaseLock() {
|
|
3843
4122
|
if (this.lockHandle === null) {
|
|
4123
|
+
this._releaseTurn();
|
|
3844
4124
|
return;
|
|
3845
4125
|
}
|
|
3846
4126
|
this.lockCount--;
|
|
@@ -3848,10 +4128,10 @@ var init_file_lock_service = __esm({
|
|
|
3848
4128
|
return;
|
|
3849
4129
|
}
|
|
3850
4130
|
try {
|
|
3851
|
-
const content = await
|
|
4131
|
+
const content = await fs7.readFile(this.lockFilePath, "utf8").catch(() => "");
|
|
3852
4132
|
const parsed = this._parseLockContent(content);
|
|
3853
4133
|
if (parsed?.uuid === this.lockUuid) {
|
|
3854
|
-
await
|
|
4134
|
+
await fs7.unlink(this.lockFilePath).catch(() => {
|
|
3855
4135
|
});
|
|
3856
4136
|
}
|
|
3857
4137
|
await this.lockHandle.close().catch(() => {
|
|
@@ -3864,19 +4144,20 @@ var init_file_lock_service = __esm({
|
|
|
3864
4144
|
}
|
|
3865
4145
|
metrics.setGauge("state_lock_held", 0);
|
|
3866
4146
|
} finally {
|
|
3867
|
-
|
|
3868
|
-
this.resolveTurn = null;
|
|
3869
|
-
if (resolve8) {
|
|
3870
|
-
resolve8();
|
|
3871
|
-
}
|
|
4147
|
+
this._releaseTurn();
|
|
3872
4148
|
}
|
|
3873
4149
|
}
|
|
3874
4150
|
/**
|
|
3875
4151
|
* Execute a function within the scope of a filesystem lock.
|
|
3876
4152
|
* This is the preferred way to use the lock service.
|
|
3877
4153
|
* Safe re-entrancy is supported within the same async flow.
|
|
4154
|
+
*
|
|
4155
|
+
* The acquire timeout is the one given to the constructor (`lockTimeout`). This
|
|
4156
|
+
* used to take a second `_timeout` argument that was accepted and then ignored
|
|
4157
|
+
* entirely — a caller passing 5000 silently got the constructed 20000 — so it is
|
|
4158
|
+
* gone rather than left to mislead. No caller was passing it.
|
|
3878
4159
|
*/
|
|
3879
|
-
async withLock(callback
|
|
4160
|
+
async withLock(callback) {
|
|
3880
4161
|
const heldLocks = lockContext.getStore();
|
|
3881
4162
|
const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
|
|
3882
4163
|
if (heldLocks?.has(lockKey)) {
|
|
@@ -3892,15 +4173,29 @@ var init_file_lock_service = __esm({
|
|
|
3892
4173
|
});
|
|
3893
4174
|
}
|
|
3894
4175
|
/**
|
|
3895
|
-
*
|
|
4176
|
+
* Clean up any lock file owned by this instance, and retire the instance.
|
|
4177
|
+
*
|
|
4178
|
+
* Called only from shutdown paths (`dispose`, `StateManager.cleanup`). It first
|
|
4179
|
+
* gives an in-flight critical section a bounded chance to release normally, then
|
|
4180
|
+
* forces the teardown if it has not. After this the instance is retired:
|
|
4181
|
+
* `acquireLock` fails fast rather than blocking on a queue nothing will ever
|
|
4182
|
+
* drain, which is what makes forcing the teardown safe to do at all.
|
|
3896
4183
|
*/
|
|
3897
4184
|
async cleanup() {
|
|
4185
|
+
const drained = await this._drainHeldLock(CLEANUP_DRAIN_TIMEOUT_MS);
|
|
4186
|
+
if (!drained) {
|
|
4187
|
+
logger.warn(
|
|
4188
|
+
`[FileLockService] Lock at ${this.lockFilePath} still held after ${CLEANUP_DRAIN_TIMEOUT_MS}ms; forcing teardown. A concurrent writer in another process may observe the lock as free.`
|
|
4189
|
+
);
|
|
4190
|
+
metrics.increment("state_lock_forced_teardown_total", 1);
|
|
4191
|
+
}
|
|
4192
|
+
this._retired = true;
|
|
3898
4193
|
if (this.lockHandle) {
|
|
3899
4194
|
try {
|
|
3900
|
-
const content = await
|
|
4195
|
+
const content = await fs7.readFile(this.lockFilePath, "utf8").catch(() => "");
|
|
3901
4196
|
const parsed = this._parseLockContent(content);
|
|
3902
4197
|
if (parsed?.uuid === this.lockUuid) {
|
|
3903
|
-
await
|
|
4198
|
+
await fs7.unlink(this.lockFilePath).catch(() => {
|
|
3904
4199
|
});
|
|
3905
4200
|
}
|
|
3906
4201
|
} catch {
|
|
@@ -3912,6 +4207,7 @@ var init_file_lock_service = __esm({
|
|
|
3912
4207
|
this.lockHandle = null;
|
|
3913
4208
|
}
|
|
3914
4209
|
this.lockCount = 0;
|
|
4210
|
+
this._releaseTurn();
|
|
3915
4211
|
}
|
|
3916
4212
|
/**
|
|
3917
4213
|
* Check if the current instance holds the lock
|
|
@@ -3996,6 +4292,93 @@ var init_file_lock_service = __esm({
|
|
|
3996
4292
|
}
|
|
3997
4293
|
});
|
|
3998
4294
|
|
|
4295
|
+
// src/utils/http-body.ts
|
|
4296
|
+
async function readBodyCapped(response, maxBytes) {
|
|
4297
|
+
const body = response.body;
|
|
4298
|
+
if (!body || typeof body.getReader !== "function") {
|
|
4299
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
4300
|
+
}
|
|
4301
|
+
const reader = body.getReader();
|
|
4302
|
+
const chunks = [];
|
|
4303
|
+
let total = 0;
|
|
4304
|
+
try {
|
|
4305
|
+
for (; ; ) {
|
|
4306
|
+
const { done, value } = await reader.read();
|
|
4307
|
+
if (done) break;
|
|
4308
|
+
if (value) {
|
|
4309
|
+
total += value.byteLength;
|
|
4310
|
+
if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
|
|
4311
|
+
chunks.push(value);
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
} finally {
|
|
4315
|
+
try {
|
|
4316
|
+
await reader.cancel();
|
|
4317
|
+
} catch {
|
|
4318
|
+
}
|
|
4319
|
+
}
|
|
4320
|
+
const out = new Uint8Array(total);
|
|
4321
|
+
let offset = 0;
|
|
4322
|
+
for (const c of chunks) {
|
|
4323
|
+
out.set(c, offset);
|
|
4324
|
+
offset += c.byteLength;
|
|
4325
|
+
}
|
|
4326
|
+
return out;
|
|
4327
|
+
}
|
|
4328
|
+
function hasStreamBody(response) {
|
|
4329
|
+
const body = response.body;
|
|
4330
|
+
return !!body && typeof body.getReader === "function";
|
|
4331
|
+
}
|
|
4332
|
+
async function readTextCapped(response, maxBytes = MAX_JSON_BODY_BYTES) {
|
|
4333
|
+
if (hasStreamBody(response)) {
|
|
4334
|
+
return new TextDecoder("utf-8").decode(await readBodyCapped(response, maxBytes));
|
|
4335
|
+
}
|
|
4336
|
+
if (typeof response.arrayBuffer === "function") {
|
|
4337
|
+
return new TextDecoder("utf-8").decode(new Uint8Array(await response.arrayBuffer()));
|
|
4338
|
+
}
|
|
4339
|
+
return response.text();
|
|
4340
|
+
}
|
|
4341
|
+
async function readJsonCapped(response, maxBytes = MAX_JSON_BODY_BYTES) {
|
|
4342
|
+
if (hasStreamBody(response) || typeof response.arrayBuffer === "function" || typeof response.text === "function") {
|
|
4343
|
+
return JSON.parse(await readTextCapped(response, maxBytes));
|
|
4344
|
+
}
|
|
4345
|
+
return response.json();
|
|
4346
|
+
}
|
|
4347
|
+
var BodyTooLargeError, MAX_JSON_BODY_BYTES, MAX_BULK_JSON_BODY_BYTES, Utf8Body;
|
|
4348
|
+
var init_http_body = __esm({
|
|
4349
|
+
"src/utils/http-body.ts"() {
|
|
4350
|
+
"use strict";
|
|
4351
|
+
BodyTooLargeError = class extends Error {
|
|
4352
|
+
constructor(bytesRead, maxBytes) {
|
|
4353
|
+
super(`Response body exceeded ${maxBytes} byte cap`);
|
|
4354
|
+
this.bytesRead = bytesRead;
|
|
4355
|
+
this.maxBytes = maxBytes;
|
|
4356
|
+
this.name = "BodyTooLargeError";
|
|
4357
|
+
}
|
|
4358
|
+
bytesRead;
|
|
4359
|
+
maxBytes;
|
|
4360
|
+
};
|
|
4361
|
+
MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
|
|
4362
|
+
MAX_BULK_JSON_BODY_BYTES = 96 * 1024 * 1024;
|
|
4363
|
+
Utf8Body = class {
|
|
4364
|
+
chunks = [];
|
|
4365
|
+
bytes = 0;
|
|
4366
|
+
/** Total bytes accumulated so far. */
|
|
4367
|
+
get byteLength() {
|
|
4368
|
+
return this.bytes;
|
|
4369
|
+
}
|
|
4370
|
+
push(chunk) {
|
|
4371
|
+
this.chunks.push(chunk);
|
|
4372
|
+
this.bytes += chunk.byteLength;
|
|
4373
|
+
}
|
|
4374
|
+
/** Concatenate and decode. Safe to call once the stream has ended. */
|
|
4375
|
+
toString() {
|
|
4376
|
+
return Buffer.concat(this.chunks).toString("utf8");
|
|
4377
|
+
}
|
|
4378
|
+
};
|
|
4379
|
+
}
|
|
4380
|
+
});
|
|
4381
|
+
|
|
3999
4382
|
// src/utils/circuit-breaker.ts
|
|
4000
4383
|
var CircuitBreaker;
|
|
4001
4384
|
var init_circuit_breaker = __esm({
|
|
@@ -4052,6 +4435,11 @@ var init_circuit_breaker = __esm({
|
|
|
4052
4435
|
metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
|
|
4053
4436
|
if (this.options.isTransientError(error)) {
|
|
4054
4437
|
this.onFailure(error);
|
|
4438
|
+
} else if (admittedAsProbe && this.state === "HALF_OPEN") {
|
|
4439
|
+
logger.warn(
|
|
4440
|
+
`[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
|
|
4441
|
+
);
|
|
4442
|
+
this.transitionTo("OPEN");
|
|
4055
4443
|
}
|
|
4056
4444
|
throw error;
|
|
4057
4445
|
} finally {
|
|
@@ -4555,7 +4943,7 @@ var init_embedder_utils = __esm({
|
|
|
4555
4943
|
});
|
|
4556
4944
|
|
|
4557
4945
|
// src/knowledge/store.ts
|
|
4558
|
-
import * as
|
|
4946
|
+
import * as fs13 from "node:fs";
|
|
4559
4947
|
import * as fsPromises from "node:fs/promises";
|
|
4560
4948
|
import * as path13 from "node:path";
|
|
4561
4949
|
function isLanceCommitConflict(err) {
|
|
@@ -4629,7 +5017,7 @@ var init_store = __esm({
|
|
|
4629
5017
|
}
|
|
4630
5018
|
async loadManifest() {
|
|
4631
5019
|
try {
|
|
4632
|
-
if (
|
|
5020
|
+
if (fs13.existsSync(this.manifestPath)) {
|
|
4633
5021
|
const content = await fsPromises.readFile(this.manifestPath, "utf-8");
|
|
4634
5022
|
const manifest = JSON.parse(content);
|
|
4635
5023
|
if (manifest.activeTableName) {
|
|
@@ -4651,7 +5039,7 @@ var init_store = __esm({
|
|
|
4651
5039
|
return true;
|
|
4652
5040
|
} catch (err) {
|
|
4653
5041
|
logger.warn("[store] Failed to save manifest:", err);
|
|
4654
|
-
if (
|
|
5042
|
+
if (fs13.existsSync(tempPath)) {
|
|
4655
5043
|
try {
|
|
4656
5044
|
await fsPromises.unlink(tempPath);
|
|
4657
5045
|
} catch {
|
|
@@ -4679,8 +5067,8 @@ var init_store = __esm({
|
|
|
4679
5067
|
async openInternal() {
|
|
4680
5068
|
if (this.db) return;
|
|
4681
5069
|
try {
|
|
4682
|
-
if (!
|
|
4683
|
-
|
|
5070
|
+
if (!fs13.existsSync(this.options.dbDir)) {
|
|
5071
|
+
fs13.mkdirSync(this.options.dbDir, { recursive: true });
|
|
4684
5072
|
}
|
|
4685
5073
|
this.db = await (await getLancedb()).connect(this.options.dbDir);
|
|
4686
5074
|
const tableNames = await this.db.tableNames();
|
|
@@ -4783,7 +5171,7 @@ var init_store = __esm({
|
|
|
4783
5171
|
const oldDir = path13.join(this.options.dbDir, `${this.tableName}.lance`);
|
|
4784
5172
|
const backupDir = path13.join(this.options.dbDir, `${backupTableName}.lance`);
|
|
4785
5173
|
try {
|
|
4786
|
-
if (
|
|
5174
|
+
if (fs13.existsSync(oldDir)) {
|
|
4787
5175
|
await fsPromises.rename(oldDir, backupDir);
|
|
4788
5176
|
logger.info(`[store] Successfully backed up database directory to ${backupDir}`);
|
|
4789
5177
|
}
|
|
@@ -5067,10 +5455,22 @@ var init_store = __esm({
|
|
|
5067
5455
|
}
|
|
5068
5456
|
}
|
|
5069
5457
|
}
|
|
5070
|
-
|
|
5071
|
-
|
|
5458
|
+
/**
|
|
5459
|
+
* @param olderThan When set, only rows with `timestamp < olderThan` are removed.
|
|
5460
|
+
* This is what lets a refresh write the replacement chunks FIRST and prune the
|
|
5461
|
+
* superseded ones after, instead of deleting first and losing the entry
|
|
5462
|
+
* outright if the insert then fails. `timestamp` is used as the discriminator
|
|
5463
|
+
* because it is a real (indexed) column — `contentHash` lives inside the
|
|
5464
|
+
* JSON-encoded `metadata` blob and cannot be filtered on.
|
|
5465
|
+
*/
|
|
5466
|
+
async deleteByUrlAndType(url, ingestionType, olderThan) {
|
|
5467
|
+
if (this.isClosing) {
|
|
5468
|
+
logger.warn(`[store] Prune of ${ingestionType} chunks for ${url} skipped \u2014 store is closing; superseded duplicates remain until the next refresh.`);
|
|
5469
|
+
metrics.increment("knowledge_store_delete_total", 1, { operation: "by_url_and_type", status: "skipped_closing" });
|
|
5470
|
+
}
|
|
5471
|
+
return this.trackOperation("deleteByUrlAndType", void 0, () => this.deleteByUrlAndTypeInner(url, ingestionType, olderThan));
|
|
5072
5472
|
}
|
|
5073
|
-
async deleteByUrlAndTypeInner(url, ingestionType) {
|
|
5473
|
+
async deleteByUrlAndTypeInner(url, ingestionType, olderThan) {
|
|
5074
5474
|
let retryCount = 0;
|
|
5075
5475
|
const MAX_RETRIES = 5;
|
|
5076
5476
|
const BASE_DELAY = 100;
|
|
@@ -5081,11 +5481,12 @@ var init_store = __esm({
|
|
|
5081
5481
|
try {
|
|
5082
5482
|
const escapedUrl = url.replace(/'/g, "''");
|
|
5083
5483
|
const escapedType = ingestionType.replace(/'/g, "''");
|
|
5084
|
-
|
|
5484
|
+
const ageClause = Number.isFinite(olderThan) ? ` AND timestamp < ${Math.floor(olderThan)}` : "";
|
|
5485
|
+
await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})${ageClause}`);
|
|
5085
5486
|
const duration = Date.now() - startTime;
|
|
5086
5487
|
metrics.observe("knowledge_store_delete_duration_ms", duration, { operation: "by_url_and_type" });
|
|
5087
5488
|
metrics.increment("knowledge_store_delete_total", 1, { operation: "by_url_and_type", status: "success" });
|
|
5088
|
-
logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)`);
|
|
5489
|
+
logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)${ageClause ? " older than the replacement" : ""}`);
|
|
5089
5490
|
return;
|
|
5090
5491
|
} catch (err) {
|
|
5091
5492
|
if (isLanceCommitConflict(err)) {
|
|
@@ -5138,8 +5539,8 @@ var init_store = __esm({
|
|
|
5138
5539
|
});
|
|
5139
5540
|
try {
|
|
5140
5541
|
const dir = path13.dirname(outputPath);
|
|
5141
|
-
await
|
|
5142
|
-
await
|
|
5542
|
+
await fs13.promises.mkdir(dir, { recursive: true });
|
|
5543
|
+
await fs13.promises.writeFile(outputPath, JSON.stringify(exportData), "utf-8");
|
|
5143
5544
|
logger.info(`[store] Successfully exported ${exportData.length} entries to ${outputPath}`);
|
|
5144
5545
|
} catch (err) {
|
|
5145
5546
|
logger.error(`[store] Failed to export knowledge store for web:`, err);
|
|
@@ -5156,7 +5557,7 @@ var init_store = __esm({
|
|
|
5156
5557
|
const scopeFilter = this.getScopeFilter();
|
|
5157
5558
|
const startTime = Date.now();
|
|
5158
5559
|
const escapedUrl = url.replace(/'/g, "''");
|
|
5159
|
-
const results = await table.query().where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`).limit(
|
|
5560
|
+
const results = await table.query().where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`).limit(16).toArray();
|
|
5160
5561
|
const duration = Date.now() - startTime;
|
|
5161
5562
|
metrics.observe("knowledge_store_query_duration_ms", duration, { operation: "rebuild_document" });
|
|
5162
5563
|
metrics.increment("knowledge_store_query_total", 1, { operation: "rebuild_document" });
|
|
@@ -5164,6 +5565,7 @@ var init_store = __esm({
|
|
|
5164
5565
|
metrics.increment("knowledge_store_cache_hits_total", 1, { status: "miss" });
|
|
5165
5566
|
return null;
|
|
5166
5567
|
}
|
|
5568
|
+
results.sort((a, b) => Number(b.timestamp) - Number(a.timestamp));
|
|
5167
5569
|
const r = results[0];
|
|
5168
5570
|
const rawAgeMs = Date.now() - Number(r.timestamp);
|
|
5169
5571
|
const hasAge = Number.isFinite(rawAgeMs);
|
|
@@ -5319,14 +5721,14 @@ var init_store = __esm({
|
|
|
5319
5721
|
for (const name of migrationDirs) {
|
|
5320
5722
|
const fullPath = path13.join(this.options.dbDir, name);
|
|
5321
5723
|
logger.info(`[store] Removing orphaned migration temp directory: ${name}`);
|
|
5322
|
-
await fsPromises.rm(fullPath, { recursive: true, force: true });
|
|
5724
|
+
await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
5323
5725
|
}
|
|
5324
5726
|
if (backupDirs.length > 1) {
|
|
5325
5727
|
backupDirs.sort();
|
|
5326
5728
|
for (const name of backupDirs.slice(0, -1)) {
|
|
5327
5729
|
const fullPath = path13.join(this.options.dbDir, name);
|
|
5328
5730
|
logger.info(`[store] Removing superseded backup directory: ${name}`);
|
|
5329
|
-
await fsPromises.rm(fullPath, { recursive: true, force: true });
|
|
5731
|
+
await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
5330
5732
|
}
|
|
5331
5733
|
}
|
|
5332
5734
|
} catch (err) {
|
|
@@ -5508,44 +5910,47 @@ var init_writer_queue = __esm({
|
|
|
5508
5910
|
async process() {
|
|
5509
5911
|
if (this.processing) return;
|
|
5510
5912
|
this.processing = true;
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
const urlKey = normalizeUrl(item.url);
|
|
5515
|
-
const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
|
|
5516
|
-
const inflight = prev.then(() => this._ingestInner(item));
|
|
5517
|
-
this.inflightByUrl.set(urlKey, inflight);
|
|
5913
|
+
try {
|
|
5914
|
+
while (this.queue.length > 0) {
|
|
5915
|
+
const item = this.queue.shift();
|
|
5518
5916
|
try {
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
}
|
|
5524
|
-
}
|
|
5525
|
-
} catch (err) {
|
|
5526
|
-
if (isEmbedderUnreachable(err)) {
|
|
5527
|
-
logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
|
|
5528
|
-
await new Promise((r) => setTimeout(r, 2e3));
|
|
5917
|
+
const urlKey = normalizeUrl(item.url);
|
|
5918
|
+
const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
|
|
5919
|
+
const inflight = prev.then(() => this._ingestInner(item));
|
|
5920
|
+
this.inflightByUrl.set(urlKey, inflight);
|
|
5529
5921
|
try {
|
|
5530
|
-
await
|
|
5531
|
-
}
|
|
5532
|
-
|
|
5533
|
-
|
|
5922
|
+
await inflight;
|
|
5923
|
+
} finally {
|
|
5924
|
+
if (this.inflightByUrl.get(urlKey) === inflight) {
|
|
5925
|
+
this.inflightByUrl.delete(urlKey);
|
|
5926
|
+
}
|
|
5927
|
+
}
|
|
5928
|
+
} catch (err) {
|
|
5929
|
+
if (isEmbedderUnreachable(err)) {
|
|
5930
|
+
logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
|
|
5931
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
5932
|
+
try {
|
|
5933
|
+
await this.ingest(item);
|
|
5934
|
+
} catch (retryErr) {
|
|
5935
|
+
logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
|
|
5936
|
+
metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "embedder_unreachable" });
|
|
5937
|
+
}
|
|
5938
|
+
} else if (isNoSpace(err)) {
|
|
5939
|
+
logger.error(`[writer-queue] ENOSPC: disk full \u2014 dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
|
|
5940
|
+
metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "enospc" });
|
|
5941
|
+
} else {
|
|
5942
|
+
logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
|
|
5943
|
+
metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "ingest_error" });
|
|
5534
5944
|
}
|
|
5535
|
-
} else if (isNoSpace(err)) {
|
|
5536
|
-
logger.error(`[writer-queue] ENOSPC: disk full \u2014 dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
|
|
5537
|
-
metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "enospc" });
|
|
5538
|
-
} else {
|
|
5539
|
-
logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
|
|
5540
|
-
metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "ingest_error" });
|
|
5541
5945
|
}
|
|
5542
5946
|
}
|
|
5543
|
-
}
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5947
|
+
} finally {
|
|
5948
|
+
this.processing = false;
|
|
5949
|
+
const resolvers = [...this.drainResolvers];
|
|
5950
|
+
this.drainResolvers = [];
|
|
5951
|
+
for (const resolve8 of resolvers) {
|
|
5952
|
+
resolve8();
|
|
5953
|
+
}
|
|
5549
5954
|
}
|
|
5550
5955
|
}
|
|
5551
5956
|
async ingest(item) {
|
|
@@ -5586,15 +5991,23 @@ var init_writer_queue = __esm({
|
|
|
5586
5991
|
};
|
|
5587
5992
|
const existing = await this.options.store.findByUrl(normalizedUrl);
|
|
5588
5993
|
const sameType = existing.filter((c) => c.metadata["ingestionType"] === incomingType);
|
|
5589
|
-
if (sameType.length > 0 && sameType[0].metadata["contentHash"] === hash) {
|
|
5590
|
-
logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) \u2014 content unchanged.`);
|
|
5591
|
-
return;
|
|
5592
|
-
}
|
|
5593
5994
|
if (sameType.length > 0) {
|
|
5594
|
-
|
|
5995
|
+
const newestTs = Math.max(...sameType.map((d) => Number(d.timestamp) || 0));
|
|
5996
|
+
const newest = sameType.find((d) => (Number(d.timestamp) || 0) === newestTs);
|
|
5997
|
+
if (newest.metadata["contentHash"] === hash) {
|
|
5998
|
+
if (sameType.some((d) => (Number(d.timestamp) || 0) < newestTs)) {
|
|
5999
|
+
await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, newestTs).catch((err) => {
|
|
6000
|
+
logger.warn(`[writer-queue] Sweeping superseded duplicates for ${normalizedUrl} (${incomingType}) failed:`, err);
|
|
6001
|
+
metrics.increment("knowledge_ingest_stale_prune_failed_total", 1);
|
|
6002
|
+
});
|
|
6003
|
+
}
|
|
6004
|
+
logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) \u2014 content unchanged.`);
|
|
6005
|
+
return;
|
|
6006
|
+
}
|
|
5595
6007
|
}
|
|
5596
6008
|
const rawChunks = this.options.chunker ? this.options.chunker.chunk(item.markdown) : [{ text: item.markdown, actual_overlap: 0 }];
|
|
5597
6009
|
if (rawChunks.length === 0) return;
|
|
6010
|
+
const insertedAt = Date.now();
|
|
5598
6011
|
const docs = rawChunks.map((chunk, i) => ({
|
|
5599
6012
|
url: normalizedUrl,
|
|
5600
6013
|
text: chunk.text,
|
|
@@ -5609,9 +6022,15 @@ var init_writer_queue = __esm({
|
|
|
5609
6022
|
chunkIndex: i,
|
|
5610
6023
|
totalChunks: rawChunks.length
|
|
5611
6024
|
},
|
|
5612
|
-
timestamp:
|
|
6025
|
+
timestamp: insertedAt
|
|
5613
6026
|
}));
|
|
5614
6027
|
await this.options.store.addDocuments(docs);
|
|
6028
|
+
if (sameType.length > 0) {
|
|
6029
|
+
await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, insertedAt).catch((err) => {
|
|
6030
|
+
logger.warn(`[writer-queue] Replacement for ${normalizedUrl} (${incomingType}) is stored, but pruning the superseded chunks failed; duplicates remain until the next refresh:`, err);
|
|
6031
|
+
metrics.increment("knowledge_ingest_stale_prune_failed_total", 1);
|
|
6032
|
+
});
|
|
6033
|
+
}
|
|
5615
6034
|
}
|
|
5616
6035
|
async drain(timeoutMs) {
|
|
5617
6036
|
return new Promise((resolve8) => {
|
|
@@ -5879,7 +6298,7 @@ var init_model_config = __esm({
|
|
|
5879
6298
|
});
|
|
5880
6299
|
|
|
5881
6300
|
// src/knowledge/index.ts
|
|
5882
|
-
import * as
|
|
6301
|
+
import * as fs14 from "node:fs";
|
|
5883
6302
|
function getMigrationStrategy(config) {
|
|
5884
6303
|
const strategy = config.MIGRATION_STRATEGY;
|
|
5885
6304
|
if (!strategy) return void 0;
|
|
@@ -5941,10 +6360,10 @@ async function createKnowledgeStoreComponents(embedderFactory, reconnectFactory,
|
|
|
5941
6360
|
}
|
|
5942
6361
|
async function forceDeleteKnowledgeStore(config, workspace) {
|
|
5943
6362
|
const dbDir = getDbDir(config, workspace);
|
|
5944
|
-
if (
|
|
6363
|
+
if (fs14.existsSync(dbDir)) {
|
|
5945
6364
|
try {
|
|
5946
|
-
|
|
5947
|
-
|
|
6365
|
+
fs14.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
6366
|
+
fs14.mkdirSync(dbDir, { recursive: true });
|
|
5948
6367
|
logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
|
|
5949
6368
|
} catch (err) {
|
|
5950
6369
|
logger.error(`[knowledge] Failed to forcefully delete directory ${dbDir}:`, err);
|
|
@@ -6079,11 +6498,11 @@ async function isModelCached(model) {
|
|
|
6079
6498
|
const env = await getHFEnv();
|
|
6080
6499
|
const cacheDir = env.cacheDir;
|
|
6081
6500
|
if (!cacheDir) return false;
|
|
6082
|
-
const { access:
|
|
6501
|
+
const { access: access3, readdir: readdir6, readFile: readFile7, stat: stat5, rm: rm3 } = await import("node:fs/promises");
|
|
6083
6502
|
const path19 = await import("node:path");
|
|
6084
6503
|
const modelDir = path19.default.join(cacheDir, model);
|
|
6085
6504
|
const onnxDir = path19.default.join(modelDir, "onnx");
|
|
6086
|
-
await
|
|
6505
|
+
await access3(path19.default.join(onnxDir, "model.onnx"));
|
|
6087
6506
|
try {
|
|
6088
6507
|
const cfgRaw = await readFile7(path19.default.join(modelDir, "config.json"), "utf-8");
|
|
6089
6508
|
JSON.parse(cfgRaw);
|
|
@@ -6100,7 +6519,7 @@ async function isModelCached(model) {
|
|
|
6100
6519
|
}
|
|
6101
6520
|
}
|
|
6102
6521
|
if (!hasTokenizer) return false;
|
|
6103
|
-
const entries = await
|
|
6522
|
+
const entries = await readdir6(onnxDir).catch(() => []);
|
|
6104
6523
|
const hasExternalWeights = entries.includes("model.onnx_data");
|
|
6105
6524
|
if (hasExternalWeights) {
|
|
6106
6525
|
const dataStat = await stat5(path19.default.join(onnxDir, "model.onnx_data")).catch(() => null);
|
|
@@ -6141,7 +6560,7 @@ async function purgeModelCache(model) {
|
|
|
6141
6560
|
logger.warn(`[embedder] Refusing to purge unsafe model-cache path for model id ${JSON.stringify(model)}`);
|
|
6142
6561
|
return;
|
|
6143
6562
|
}
|
|
6144
|
-
await rm3(modelDir, { recursive: true, force: true });
|
|
6563
|
+
await rm3(modelDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
6145
6564
|
logger.warn(`[embedder] Purged corrupt model cache at ${modelDir}`);
|
|
6146
6565
|
} catch (err) {
|
|
6147
6566
|
logger.warn("[embedder] Failed to purge model cache:", err);
|
|
@@ -6341,19 +6760,21 @@ function runProbe(model, cacheDir, timeoutMs) {
|
|
|
6341
6760
|
finish(false);
|
|
6342
6761
|
}, timeoutMs);
|
|
6343
6762
|
if (typeof timer.unref === "function") timer.unref();
|
|
6344
|
-
|
|
6345
|
-
|
|
6763
|
+
const stdoutChunks = [];
|
|
6764
|
+
const stderrChunks = [];
|
|
6346
6765
|
child.stdout?.on("data", (d) => {
|
|
6347
|
-
|
|
6766
|
+
stdoutChunks.push(Buffer.from(d));
|
|
6348
6767
|
});
|
|
6349
6768
|
child.stderr?.on("data", (d) => {
|
|
6350
|
-
|
|
6769
|
+
stderrChunks.push(Buffer.from(d));
|
|
6351
6770
|
});
|
|
6352
6771
|
child.on("error", (err) => {
|
|
6353
6772
|
logger.warn("[embedder] WebGPU probe process error \u2014 defaulting to CPU:", err);
|
|
6354
6773
|
finish(false);
|
|
6355
6774
|
});
|
|
6356
6775
|
child.on("close", (code, signal) => {
|
|
6776
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
6777
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
6357
6778
|
if (stdout.includes("PROBE_OK")) {
|
|
6358
6779
|
finish(true);
|
|
6359
6780
|
return;
|
|
@@ -6446,6 +6867,14 @@ var init_embedder = __esm({
|
|
|
6446
6867
|
idleTimer = null;
|
|
6447
6868
|
IDLE_TIMEOUT_MS = 60 * 1e3;
|
|
6448
6869
|
activeEmbeddings = 0;
|
|
6870
|
+
/**
|
|
6871
|
+
* Why the in-flight dispose was started. An IDLE dispose is a memory-reclaim
|
|
6872
|
+
* pause that a later embed is expected to undo, so initialize() waits it out and
|
|
6873
|
+
* re-initializes. A TERMINAL dispose (shutdown, config change, explicit
|
|
6874
|
+
* disposal) must never be undone — re-initializing there would resurrect the
|
|
6875
|
+
* ONNX pipeline during teardown. Only meaningful while state === 'disposing'.
|
|
6876
|
+
*/
|
|
6877
|
+
disposeReason = "terminal";
|
|
6449
6878
|
constructor(options) {
|
|
6450
6879
|
this.model = options.model;
|
|
6451
6880
|
this.poolingMode = options.pooling ?? "mean";
|
|
@@ -6479,7 +6908,7 @@ var init_embedder = __esm({
|
|
|
6479
6908
|
}
|
|
6480
6909
|
if (this.state === "ready") {
|
|
6481
6910
|
logger.info(`[embedder] Idle timeout reached (${this.IDLE_TIMEOUT_MS}ms), releasing GPU memory...`);
|
|
6482
|
-
this.dispose().catch((err) => logger.warn("[embedder] Failed to dispose on idle:", err));
|
|
6911
|
+
this.dispose("idle").catch((err) => logger.warn("[embedder] Failed to dispose on idle:", err));
|
|
6483
6912
|
}
|
|
6484
6913
|
}, this.IDLE_TIMEOUT_MS);
|
|
6485
6914
|
if (this.idleTimer) safeUnref(this.idleTimer);
|
|
@@ -6493,7 +6922,20 @@ var init_embedder = __esm({
|
|
|
6493
6922
|
async initialize() {
|
|
6494
6923
|
if (this.state === "ready") return;
|
|
6495
6924
|
if (this.state === "disposing") {
|
|
6496
|
-
|
|
6925
|
+
if (this.disposeReason === "idle") {
|
|
6926
|
+
logger.debug("[embedder] initialize() raced an idle dispose \u2014 waiting for it to finish, then re-initializing.");
|
|
6927
|
+
await this.disposePromise?.catch(() => {
|
|
6928
|
+
});
|
|
6929
|
+
if (this.state === "ready") return;
|
|
6930
|
+
if (this.disposeReason === "terminal") {
|
|
6931
|
+
throw new Error("Cannot initialize while disposing");
|
|
6932
|
+
}
|
|
6933
|
+
if (this.state === "disposing") {
|
|
6934
|
+
throw new Error("Cannot initialize while disposing");
|
|
6935
|
+
}
|
|
6936
|
+
} else {
|
|
6937
|
+
throw new Error("Cannot initialize while disposing");
|
|
6938
|
+
}
|
|
6497
6939
|
}
|
|
6498
6940
|
if (this.initializingPromise) {
|
|
6499
6941
|
return this.initializingPromise;
|
|
@@ -6815,12 +7257,21 @@ var init_embedder = __esm({
|
|
|
6815
7257
|
this.recoveryPromise = null;
|
|
6816
7258
|
}
|
|
6817
7259
|
}
|
|
6818
|
-
|
|
7260
|
+
/**
|
|
7261
|
+
* @param reason `'idle'` for the memory-reclaim pause driven by the idle timer —
|
|
7262
|
+
* a later embed is allowed to revive the embedder. Anything else is terminal
|
|
7263
|
+
* (shutdown, config change, explicit disposal) and must never be revived, so
|
|
7264
|
+
* the default is deliberately the safe one: callers that do not opt in get
|
|
7265
|
+
* terminal semantics.
|
|
7266
|
+
*/
|
|
7267
|
+
async dispose(reason = "terminal") {
|
|
6819
7268
|
if (this.state === "idle") return;
|
|
6820
7269
|
this.stopIdleTimer();
|
|
6821
7270
|
if (this.state === "disposing" && this.disposePromise) {
|
|
7271
|
+
if (reason === "terminal") this.disposeReason = "terminal";
|
|
6822
7272
|
return this.disposePromise;
|
|
6823
7273
|
}
|
|
7274
|
+
this.disposeReason = reason;
|
|
6824
7275
|
this.state = "disposing";
|
|
6825
7276
|
this.disposePromise = (async () => {
|
|
6826
7277
|
const maxWaitMs = 5e3;
|
|
@@ -6865,8 +7316,21 @@ var init_embedder = __esm({
|
|
|
6865
7316
|
|
|
6866
7317
|
// src/infrastructure/embedding/embedding-server.ts
|
|
6867
7318
|
import * as http4 from "node:http";
|
|
7319
|
+
import * as crypto7 from "node:crypto";
|
|
6868
7320
|
import * as path15 from "node:path";
|
|
6869
|
-
|
|
7321
|
+
function getEmbeddingServerAuthSecret() {
|
|
7322
|
+
if (!_embeddingAuthSecret) {
|
|
7323
|
+
_embeddingAuthSecret = crypto7.randomBytes(32).toString("hex");
|
|
7324
|
+
}
|
|
7325
|
+
return _embeddingAuthSecret;
|
|
7326
|
+
}
|
|
7327
|
+
function isAuthorized(presented) {
|
|
7328
|
+
const header = Array.isArray(presented) ? presented[0] : presented;
|
|
7329
|
+
const expected = Buffer.from(getEmbeddingServerAuthSecret(), "utf8");
|
|
7330
|
+
const actual = Buffer.from(header ?? "", "utf8");
|
|
7331
|
+
return actual.length === expected.length && crypto7.timingSafeEqual(actual, expected);
|
|
7332
|
+
}
|
|
7333
|
+
var _embeddingAuthSecret, SerialQueue, EmbeddingServer;
|
|
6870
7334
|
var init_embedding_server = __esm({
|
|
6871
7335
|
"src/infrastructure/embedding/embedding-server.ts"() {
|
|
6872
7336
|
"use strict";
|
|
@@ -6874,6 +7338,8 @@ var init_embedding_server = __esm({
|
|
|
6874
7338
|
init_stdio_capture();
|
|
6875
7339
|
init_log_utils();
|
|
6876
7340
|
init_disk_space_checker();
|
|
7341
|
+
init_http_body();
|
|
7342
|
+
_embeddingAuthSecret = null;
|
|
6877
7343
|
SerialQueue = class {
|
|
6878
7344
|
running = false;
|
|
6879
7345
|
tasks = [];
|
|
@@ -7176,12 +7642,18 @@ var init_embedding_server = __esm({
|
|
|
7176
7642
|
}
|
|
7177
7643
|
// ---- HTTP request handler ----
|
|
7178
7644
|
async handleRequest(req, res) {
|
|
7645
|
+
if (!isAuthorized(req.headers["x-embedding-auth"])) {
|
|
7646
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
7647
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
7648
|
+
req.resume();
|
|
7649
|
+
return;
|
|
7650
|
+
}
|
|
7179
7651
|
const MAX_BODY_SIZE = 50 * 1024 * 1024;
|
|
7180
|
-
|
|
7652
|
+
const collected = new Utf8Body();
|
|
7181
7653
|
let oversized = false;
|
|
7182
7654
|
req.on("data", (chunk) => {
|
|
7183
|
-
|
|
7184
|
-
if (
|
|
7655
|
+
collected.push(chunk);
|
|
7656
|
+
if (collected.byteLength > MAX_BODY_SIZE) {
|
|
7185
7657
|
oversized = true;
|
|
7186
7658
|
req.destroy(new Error("Payload too large"));
|
|
7187
7659
|
}
|
|
@@ -7205,6 +7677,7 @@ var init_embedding_server = __esm({
|
|
|
7205
7677
|
}
|
|
7206
7678
|
finish();
|
|
7207
7679
|
});
|
|
7680
|
+
req.on("close", finish);
|
|
7208
7681
|
req.on("end", async () => {
|
|
7209
7682
|
try {
|
|
7210
7683
|
if (req.method === "GET" && req.url === "/health") {
|
|
@@ -7224,7 +7697,7 @@ var init_embedding_server = __esm({
|
|
|
7224
7697
|
finish();
|
|
7225
7698
|
return;
|
|
7226
7699
|
}
|
|
7227
|
-
const data = JSON.parse(
|
|
7700
|
+
const data = JSON.parse(collected.toString());
|
|
7228
7701
|
switch (req.url) {
|
|
7229
7702
|
case "/embed": {
|
|
7230
7703
|
this.assertServing();
|
|
@@ -7274,17 +7747,24 @@ var init_embedding_client = __esm({
|
|
|
7274
7747
|
"src/infrastructure/embedding/embedding-client.ts"() {
|
|
7275
7748
|
"use strict";
|
|
7276
7749
|
init_logger();
|
|
7750
|
+
init_http_body();
|
|
7277
7751
|
EmbeddingClient = class {
|
|
7278
|
-
constructor(port, _device = "unknown") {
|
|
7752
|
+
constructor(port, _device = "unknown", authSecret) {
|
|
7279
7753
|
this.port = port;
|
|
7280
7754
|
this._device = _device;
|
|
7755
|
+
this.authSecret = authSecret;
|
|
7281
7756
|
logger.log(`[EmbeddingClient] Connecting to embedding server at http://127.0.0.1:${port}`);
|
|
7282
7757
|
}
|
|
7283
7758
|
port;
|
|
7284
7759
|
_device;
|
|
7760
|
+
authSecret;
|
|
7285
7761
|
// Public so store.ts can set the dimension from existing table schema
|
|
7286
7762
|
// before the embedder has been fully warmed up (see Embedder.setDimension).
|
|
7287
7763
|
dimension = null;
|
|
7764
|
+
/** Auth header for the leader, or nothing when connecting to a pre-auth leader. */
|
|
7765
|
+
authHeaders() {
|
|
7766
|
+
return this.authSecret ? { "x-embedding-auth": this.authSecret } : {};
|
|
7767
|
+
}
|
|
7288
7768
|
// ---- IEmbedder ----
|
|
7289
7769
|
getDevice() {
|
|
7290
7770
|
return this._device;
|
|
@@ -7338,26 +7818,27 @@ var init_embedding_client = __esm({
|
|
|
7338
7818
|
hostname: "127.0.0.1",
|
|
7339
7819
|
port: this.port,
|
|
7340
7820
|
path: path19,
|
|
7341
|
-
method: "GET"
|
|
7821
|
+
method: "GET",
|
|
7822
|
+
headers: this.authHeaders()
|
|
7342
7823
|
},
|
|
7343
7824
|
(res) => {
|
|
7344
|
-
|
|
7825
|
+
const collected = new Utf8Body();
|
|
7345
7826
|
res.on("data", (chunk) => {
|
|
7346
|
-
|
|
7827
|
+
collected.push(chunk);
|
|
7347
7828
|
});
|
|
7348
7829
|
res.on("end", () => {
|
|
7349
7830
|
if (resolved) return;
|
|
7350
7831
|
resolved = true;
|
|
7351
7832
|
clearTimeout(timer);
|
|
7352
7833
|
try {
|
|
7353
|
-
const parsed = JSON.parse(
|
|
7834
|
+
const parsed = JSON.parse(collected.toString());
|
|
7354
7835
|
if (res.statusCode !== 200) {
|
|
7355
7836
|
reject(new Error(`[EmbeddingClient] GET ${path19} returned HTTP ${res.statusCode}`));
|
|
7356
7837
|
} else {
|
|
7357
7838
|
resolve8(parsed);
|
|
7358
7839
|
}
|
|
7359
7840
|
} catch (_e) {
|
|
7360
|
-
reject(new Error(`[EmbeddingClient] Failed to parse GET ${path19} response: ${
|
|
7841
|
+
reject(new Error(`[EmbeddingClient] Failed to parse GET ${path19} response: ${collected.toString()}`));
|
|
7361
7842
|
}
|
|
7362
7843
|
});
|
|
7363
7844
|
res.on("error", (err) => {
|
|
@@ -7399,20 +7880,21 @@ var init_embedding_client = __esm({
|
|
|
7399
7880
|
method: "POST",
|
|
7400
7881
|
headers: {
|
|
7401
7882
|
"Content-Type": "application/json",
|
|
7402
|
-
"Content-Length": Buffer.byteLength(body)
|
|
7883
|
+
"Content-Length": Buffer.byteLength(body),
|
|
7884
|
+
...this.authHeaders()
|
|
7403
7885
|
}
|
|
7404
7886
|
},
|
|
7405
7887
|
(res) => {
|
|
7406
|
-
|
|
7888
|
+
const collected = new Utf8Body();
|
|
7407
7889
|
res.on("data", (chunk) => {
|
|
7408
|
-
|
|
7890
|
+
collected.push(chunk);
|
|
7409
7891
|
});
|
|
7410
7892
|
res.on("end", () => {
|
|
7411
7893
|
if (resolved) return;
|
|
7412
7894
|
resolved = true;
|
|
7413
7895
|
clearTimeout(timer);
|
|
7414
7896
|
try {
|
|
7415
|
-
const parsed = JSON.parse(
|
|
7897
|
+
const parsed = JSON.parse(collected.toString());
|
|
7416
7898
|
if (res.statusCode !== 200) {
|
|
7417
7899
|
const msg2 = parsed?.["error"];
|
|
7418
7900
|
reject(new Error(typeof msg2 === "string" ? msg2 : `HTTP ${res.statusCode}`));
|
|
@@ -7420,7 +7902,7 @@ var init_embedding_client = __esm({
|
|
|
7420
7902
|
resolve8(parsed);
|
|
7421
7903
|
}
|
|
7422
7904
|
} catch (_e) {
|
|
7423
|
-
reject(new Error(`[EmbeddingClient] Failed to parse POST ${path19} response: ${
|
|
7905
|
+
reject(new Error(`[EmbeddingClient] Failed to parse POST ${path19} response: ${collected.toString()}`));
|
|
7424
7906
|
}
|
|
7425
7907
|
});
|
|
7426
7908
|
res.on("error", (err) => {
|
|
@@ -7446,7 +7928,7 @@ var init_embedding_client = __esm({
|
|
|
7446
7928
|
});
|
|
7447
7929
|
|
|
7448
7930
|
// src/infrastructure/embedding/embedding-factory.ts
|
|
7449
|
-
import * as
|
|
7931
|
+
import * as crypto8 from "node:crypto";
|
|
7450
7932
|
import * as net2 from "node:net";
|
|
7451
7933
|
async function isPortListening2(port, timeoutMs = 2e3) {
|
|
7452
7934
|
return new Promise((resolve8) => {
|
|
@@ -7491,13 +7973,14 @@ async function getEmbedder(config, _attempt = 0) {
|
|
|
7491
7973
|
return _embeddingInstance;
|
|
7492
7974
|
}
|
|
7493
7975
|
logger.info(`[EmbeddingFactory] Configuration change detected (${_cachedModel} on ${_cachedDevice} -> ${cfg.EMBEDDING_MODEL} on ${cfg.EMBEDDING_DEVICE}). Disposing stale instance.`);
|
|
7494
|
-
|
|
7976
|
+
const stale = _embeddingInstance;
|
|
7495
7977
|
_embeddingInstance = null;
|
|
7978
|
+
await stale.dispose?.();
|
|
7496
7979
|
}
|
|
7497
|
-
if (_embeddingInitPromise) return _embeddingInitPromise;
|
|
7980
|
+
if (_embeddingInitPromise && _attempt === 0) return _embeddingInitPromise;
|
|
7498
7981
|
let p;
|
|
7499
7982
|
const init = async () => {
|
|
7500
|
-
const serverId =
|
|
7983
|
+
const serverId = crypto8.randomUUID();
|
|
7501
7984
|
const stateManager = await getService(ServiceNames.STATE_MANAGER);
|
|
7502
7985
|
const processLifecycle = await getService(ServiceNames.PROCESS_LIFECYCLE);
|
|
7503
7986
|
const myStartTime = await processLifecycle.getCurrentProcessStartTime();
|
|
@@ -7519,6 +8002,9 @@ async function getEmbedder(config, _attempt = 0) {
|
|
|
7519
8002
|
pid: process.pid,
|
|
7520
8003
|
serverId,
|
|
7521
8004
|
model: cfg.EMBEDDING_MODEL,
|
|
8005
|
+
// Published with the claim, not with the port, so a follower that adopts
|
|
8006
|
+
// this leader always has the secret available whenever the port is.
|
|
8007
|
+
authSecret: getEmbeddingServerAuthSecret(),
|
|
7522
8008
|
...myStartTime !== null ? { startTime: myStartTime } : {}
|
|
7523
8009
|
};
|
|
7524
8010
|
iAmCandidate = true;
|
|
@@ -7559,7 +8045,7 @@ async function getEmbedder(config, _attempt = 0) {
|
|
|
7559
8045
|
const portOk = await isPortListening2(info.port);
|
|
7560
8046
|
if (portOk) {
|
|
7561
8047
|
logger.info(`[EmbeddingFactory] Connecting to embedding server on port ${info.port}`);
|
|
7562
|
-
const client = new EmbeddingClient(info.port);
|
|
8048
|
+
const client = new EmbeddingClient(info.port, cfg.EMBEDDING_DEVICE, info.authSecret);
|
|
7563
8049
|
await client.fetchHealth();
|
|
7564
8050
|
_embeddingInstance = client;
|
|
7565
8051
|
_cachedModel = cfg.EMBEDDING_MODEL;
|
|
@@ -7575,7 +8061,6 @@ async function getEmbedder(config, _attempt = 0) {
|
|
|
7575
8061
|
}
|
|
7576
8062
|
}
|
|
7577
8063
|
}
|
|
7578
|
-
_embeddingInitPromise = null;
|
|
7579
8064
|
if (_attempt + 1 >= MAX_LEADER_WAIT_RETRIES) {
|
|
7580
8065
|
throw new Error(
|
|
7581
8066
|
`[EmbeddingFactory] Could not obtain an embedding server after ${MAX_LEADER_WAIT_RETRIES} attempts (no process became leader; model init may be failing repeatedly).`
|
|
@@ -7610,11 +8095,10 @@ async function getEmbedder(config, _attempt = 0) {
|
|
|
7610
8095
|
};
|
|
7611
8096
|
p = init();
|
|
7612
8097
|
_embeddingInitPromise = p;
|
|
7613
|
-
|
|
7614
|
-
_embeddingInitPromise = null;
|
|
7615
|
-
}
|
|
7616
|
-
|
|
7617
|
-
});
|
|
8098
|
+
const clearIfCurrent = () => {
|
|
8099
|
+
if (_embeddingInitPromise === p) _embeddingInitPromise = null;
|
|
8100
|
+
};
|
|
8101
|
+
p.then(clearIfCurrent).catch(clearIfCurrent);
|
|
7618
8102
|
return p;
|
|
7619
8103
|
}
|
|
7620
8104
|
async function clearEmbeddingInstance() {
|
|
@@ -8039,7 +8523,7 @@ function resolveResearchModel(options) {
|
|
|
8039
8523
|
if (anyModel.length > 0) {
|
|
8040
8524
|
return pickPreferredAvailable(anyModel, readModelsJsonProviderOrder());
|
|
8041
8525
|
}
|
|
8042
|
-
throw new Error(
|
|
8526
|
+
throw new Error(buildNoModelAvailableMessage());
|
|
8043
8527
|
}
|
|
8044
8528
|
var init_research_model_resolver = __esm({
|
|
8045
8529
|
"src/core/llm/research-model-resolver.ts"() {
|
|
@@ -8341,7 +8825,7 @@ async function runBackgroundExtraction(model, auth, conversationHistory, queries
|
|
|
8341
8825
|
const delay2 = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
|
|
8342
8826
|
metrics.increment("research_knowledge_search_synthesis_retries_total", 1);
|
|
8343
8827
|
logger.warn(`[research-knowledge-search] Synthesis attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg2}); retrying in ${delay2}ms`);
|
|
8344
|
-
await abortableDelay(delay2, signal);
|
|
8828
|
+
await abortableDelay(delay2, signal, true);
|
|
8345
8829
|
}
|
|
8346
8830
|
}
|
|
8347
8831
|
const extracted = extractJson(responseText, "object");
|
|
@@ -8429,7 +8913,7 @@ ${desc}`;
|
|
|
8429
8913
|
const delay2 = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
|
|
8430
8914
|
metrics.increment("research_knowledge_search_triage_retries_total", 1);
|
|
8431
8915
|
logger.warn(`[research-knowledge-search] Triage attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg2}); retrying in ${delay2}ms`);
|
|
8432
|
-
await abortableDelay(delay2, signal);
|
|
8916
|
+
await abortableDelay(delay2, signal, true);
|
|
8433
8917
|
}
|
|
8434
8918
|
}
|
|
8435
8919
|
const extracted = extractJson(responseText, "object");
|
|
@@ -8642,6 +9126,13 @@ function createResearchKnowledgeSearchTool(iface) {
|
|
|
8642
9126
|
return buildSteeringResult(result, urls);
|
|
8643
9127
|
} catch (error) {
|
|
8644
9128
|
const durationMs = Date.now() - startTime;
|
|
9129
|
+
const msg2 = error instanceof Error ? error.message : String(error);
|
|
9130
|
+
if (signal?.aborted || error instanceof Error && error.name === "AbortError" || /^(aborted|research (aborted|cancelled))$/i.test(msg2)) {
|
|
9131
|
+
metrics.observe("research_knowledge_search_duration_ms", durationMs, { status: "cancelled" });
|
|
9132
|
+
metrics.increment("research_knowledge_search_total", 1, { status: "cancelled" });
|
|
9133
|
+
logger.debug("[research-knowledge-search] Cancelled mid-search \u2014 propagating rather than reporting an empty store.");
|
|
9134
|
+
throw error;
|
|
9135
|
+
}
|
|
8645
9136
|
metrics.observe("research_knowledge_search_duration_ms", durationMs, { status: "error" });
|
|
8646
9137
|
metrics.increment("research_knowledge_search_total", 1, { status: "error" });
|
|
8647
9138
|
logger.error("[research-knowledge-search] Tool execution failed:", error);
|
|
@@ -8712,10 +9203,19 @@ __export(skill_installer_exports, {
|
|
|
8712
9203
|
skillUninstallCandidates: () => skillUninstallCandidates,
|
|
8713
9204
|
uninstallSkill: () => uninstallSkill
|
|
8714
9205
|
});
|
|
8715
|
-
import * as
|
|
9206
|
+
import * as crypto10 from "node:crypto";
|
|
9207
|
+
import * as fs16 from "node:fs";
|
|
8716
9208
|
import * as os6 from "node:os";
|
|
8717
9209
|
import * as path17 from "node:path";
|
|
8718
9210
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
9211
|
+
function readSkillVersion(sourceDir) {
|
|
9212
|
+
try {
|
|
9213
|
+
const md = fs16.readFileSync(path17.join(sourceDir, "SKILL.md"), "utf-8");
|
|
9214
|
+
return /"version"\s*:\s*"([^"]+)"/.exec(md)?.[1] ?? null;
|
|
9215
|
+
} catch {
|
|
9216
|
+
return null;
|
|
9217
|
+
}
|
|
9218
|
+
}
|
|
8719
9219
|
function homeDir(opts) {
|
|
8720
9220
|
return opts?.home ?? os6.homedir();
|
|
8721
9221
|
}
|
|
@@ -8742,7 +9242,7 @@ function resolveSkillSourceDir(explicit) {
|
|
|
8742
9242
|
}
|
|
8743
9243
|
for (const c of candidates) {
|
|
8744
9244
|
try {
|
|
8745
|
-
if (
|
|
9245
|
+
if (fs16.existsSync(path17.join(c, "SKILL.md"))) return path17.resolve(c);
|
|
8746
9246
|
} catch {
|
|
8747
9247
|
}
|
|
8748
9248
|
}
|
|
@@ -8754,8 +9254,8 @@ function getManifestPath(opts) {
|
|
|
8754
9254
|
function readManifest(opts) {
|
|
8755
9255
|
const p = getManifestPath(opts);
|
|
8756
9256
|
try {
|
|
8757
|
-
if (
|
|
8758
|
-
const parsed = JSON.parse(
|
|
9257
|
+
if (fs16.existsSync(p)) {
|
|
9258
|
+
const parsed = JSON.parse(fs16.readFileSync(p, "utf-8"));
|
|
8759
9259
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
8760
9260
|
return { version: 1, package: PACKAGE_NAME, entries: parsed.entries };
|
|
8761
9261
|
}
|
|
@@ -8766,10 +9266,10 @@ function readManifest(opts) {
|
|
|
8766
9266
|
}
|
|
8767
9267
|
function writeManifest(manifest, opts) {
|
|
8768
9268
|
const p = getManifestPath(opts);
|
|
8769
|
-
|
|
9269
|
+
fs16.mkdirSync(path17.dirname(p), { recursive: true });
|
|
8770
9270
|
const tmp = `${p}.tmp`;
|
|
8771
|
-
|
|
8772
|
-
|
|
9271
|
+
fs16.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
9272
|
+
fs16.renameSync(tmp, p);
|
|
8773
9273
|
}
|
|
8774
9274
|
function upsertEntry(manifest, entry) {
|
|
8775
9275
|
const idx = manifest.entries.findIndex((e) => e.path === entry.path);
|
|
@@ -8778,9 +9278,9 @@ function upsertEntry(manifest, entry) {
|
|
|
8778
9278
|
}
|
|
8779
9279
|
function isOwnedSymlink(targetPath, expectedSource) {
|
|
8780
9280
|
try {
|
|
8781
|
-
const st =
|
|
9281
|
+
const st = fs16.lstatSync(targetPath);
|
|
8782
9282
|
if (!st.isSymbolicLink()) return false;
|
|
8783
|
-
const dest = path17.resolve(path17.dirname(targetPath),
|
|
9283
|
+
const dest = path17.resolve(path17.dirname(targetPath), fs16.readlinkSync(targetPath));
|
|
8784
9284
|
if (expectedSource && path17.resolve(expectedSource) === dest) return true;
|
|
8785
9285
|
return /[/\\]pi-research[/\\]skills[/\\]pi-research$/.test(dest);
|
|
8786
9286
|
} catch {
|
|
@@ -8789,7 +9289,7 @@ function isOwnedSymlink(targetPath, expectedSource) {
|
|
|
8789
9289
|
}
|
|
8790
9290
|
function isOwnedCopy(targetPath) {
|
|
8791
9291
|
try {
|
|
8792
|
-
const md =
|
|
9292
|
+
const md = fs16.readFileSync(path17.join(targetPath, "SKILL.md"), "utf-8");
|
|
8793
9293
|
return md.includes(PACKAGE_NAME);
|
|
8794
9294
|
} catch {
|
|
8795
9295
|
return false;
|
|
@@ -8802,12 +9302,12 @@ function detectHarnesses(opts) {
|
|
|
8802
9302
|
const absSkillPath = path17.join(absSkillsDir, SKILL_NAME);
|
|
8803
9303
|
let present = false;
|
|
8804
9304
|
try {
|
|
8805
|
-
present =
|
|
9305
|
+
present = fs16.existsSync(path17.join(home, h.baseDir));
|
|
8806
9306
|
} catch {
|
|
8807
9307
|
}
|
|
8808
9308
|
let installed = "none";
|
|
8809
9309
|
try {
|
|
8810
|
-
if (
|
|
9310
|
+
if (fs16.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
|
|
8811
9311
|
if (isOwnedSymlink(absSkillPath)) installed = "owned-symlink";
|
|
8812
9312
|
else if (isOwnedCopy(absSkillPath)) installed = "owned-copy";
|
|
8813
9313
|
else installed = "foreign";
|
|
@@ -8819,7 +9319,7 @@ function detectHarnesses(opts) {
|
|
|
8819
9319
|
}
|
|
8820
9320
|
function isSymlinkPresent(p) {
|
|
8821
9321
|
try {
|
|
8822
|
-
return
|
|
9322
|
+
return fs16.lstatSync(p).isSymbolicLink();
|
|
8823
9323
|
} catch {
|
|
8824
9324
|
return false;
|
|
8825
9325
|
}
|
|
@@ -8836,6 +9336,7 @@ function installSkill(toolIds, opts = {}) {
|
|
|
8836
9336
|
const manifest = readManifest(opts);
|
|
8837
9337
|
const results = [];
|
|
8838
9338
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
9339
|
+
const skillVersion = readSkillVersion(source);
|
|
8839
9340
|
for (const id of toolIds) {
|
|
8840
9341
|
const def = HARNESSES.find((h) => h.id === id);
|
|
8841
9342
|
if (!def) {
|
|
@@ -8847,13 +9348,17 @@ function installSkill(toolIds, opts = {}) {
|
|
|
8847
9348
|
const type = opts.copy ? "copy" : "symlink";
|
|
8848
9349
|
if (isOwnedSymlink(absSkillPath, source) || isOwnedCopy(absSkillPath)) {
|
|
8849
9350
|
try {
|
|
8850
|
-
const lst =
|
|
9351
|
+
const lst = fs16.lstatSync(absSkillPath);
|
|
8851
9352
|
if (lst.isSymbolicLink()) {
|
|
8852
|
-
const dest = path17.resolve(path17.dirname(absSkillPath),
|
|
9353
|
+
const dest = path17.resolve(path17.dirname(absSkillPath), fs16.readlinkSync(absSkillPath));
|
|
8853
9354
|
if (dest !== path17.resolve(source)) {
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
9355
|
+
if (opts.dryRun) {
|
|
9356
|
+
results.push({ tool: id, path: absSkillPath, type: "symlink", status: "planned", message: "would re-point stale symlink to current source" });
|
|
9357
|
+
continue;
|
|
9358
|
+
}
|
|
9359
|
+
fs16.unlinkSync(absSkillPath);
|
|
9360
|
+
fs16.symlinkSync(source, absSkillPath, process.platform === "win32" ? "junction" : "dir");
|
|
9361
|
+
upsertEntry(manifest, { tool: id, path: absSkillPath, type: "symlink", source, createdAt: now, ...skillVersion ? { version: skillVersion } : {} });
|
|
8857
9362
|
results.push({ tool: id, path: absSkillPath, type: "symlink", status: "installed", message: "re-pointed stale symlink to current source" });
|
|
8858
9363
|
continue;
|
|
8859
9364
|
}
|
|
@@ -8861,10 +9366,10 @@ function installSkill(toolIds, opts = {}) {
|
|
|
8861
9366
|
} catch {
|
|
8862
9367
|
}
|
|
8863
9368
|
results.push({ tool: id, path: absSkillPath, type, status: "already-installed" });
|
|
8864
|
-
upsertEntry(manifest, { tool: id, path: absSkillPath, type: isSymlinkPresent(absSkillPath) ? "symlink" : "copy", source, createdAt: now });
|
|
9369
|
+
upsertEntry(manifest, { tool: id, path: absSkillPath, type: isSymlinkPresent(absSkillPath) ? "symlink" : "copy", source, createdAt: now, ...skillVersion ? { version: skillVersion } : {} });
|
|
8865
9370
|
continue;
|
|
8866
9371
|
}
|
|
8867
|
-
if (
|
|
9372
|
+
if (fs16.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
|
|
8868
9373
|
results.push({ tool: id, path: absSkillPath, type, status: "skipped-foreign", message: 'a non-pi-research "research" skill already exists here; left untouched' });
|
|
8869
9374
|
continue;
|
|
8870
9375
|
}
|
|
@@ -8873,20 +9378,20 @@ function installSkill(toolIds, opts = {}) {
|
|
|
8873
9378
|
continue;
|
|
8874
9379
|
}
|
|
8875
9380
|
try {
|
|
8876
|
-
|
|
9381
|
+
fs16.mkdirSync(absSkillsDir, { recursive: true });
|
|
8877
9382
|
if (type === "symlink") {
|
|
8878
9383
|
try {
|
|
8879
|
-
|
|
9384
|
+
fs16.symlinkSync(source, absSkillPath, process.platform === "win32" ? "junction" : "dir");
|
|
8880
9385
|
} catch (err) {
|
|
8881
9386
|
copyDir(source, absSkillPath);
|
|
8882
|
-
upsertEntry(manifest, { tool: id, path: absSkillPath, type: "copy", source, createdAt: now });
|
|
9387
|
+
upsertEntry(manifest, { tool: id, path: absSkillPath, type: "copy", source, createdAt: now, ...skillVersion ? { version: skillVersion } : {} });
|
|
8883
9388
|
results.push({ tool: id, path: absSkillPath, type: "copy", status: "installed", message: `symlink unavailable (${err?.code ?? err?.message}); copied instead` });
|
|
8884
9389
|
continue;
|
|
8885
9390
|
}
|
|
8886
9391
|
} else {
|
|
8887
9392
|
copyDir(source, absSkillPath);
|
|
8888
9393
|
}
|
|
8889
|
-
upsertEntry(manifest, { tool: id, path: absSkillPath, type, source, createdAt: now });
|
|
9394
|
+
upsertEntry(manifest, { tool: id, path: absSkillPath, type, source, createdAt: now, ...skillVersion ? { version: skillVersion } : {} });
|
|
8890
9395
|
results.push({ tool: id, path: absSkillPath, type, status: "installed" });
|
|
8891
9396
|
} catch (err) {
|
|
8892
9397
|
results.push({ tool: id, path: absSkillPath, type, status: "error", message: err?.message ?? String(err) });
|
|
@@ -8913,7 +9418,7 @@ function uninstallSkill(toolIds, opts = {}) {
|
|
|
8913
9418
|
continue;
|
|
8914
9419
|
}
|
|
8915
9420
|
const owned = isOwnedSymlink(skillPath) || isOwnedCopy(skillPath);
|
|
8916
|
-
const present =
|
|
9421
|
+
const present = fs16.existsSync(skillPath) || isSymlinkPresent(skillPath);
|
|
8917
9422
|
if (!present) {
|
|
8918
9423
|
results.push({ tool, path: skillPath, status: "not-present" });
|
|
8919
9424
|
continue;
|
|
@@ -8931,8 +9436,8 @@ function uninstallSkill(toolIds, opts = {}) {
|
|
|
8931
9436
|
continue;
|
|
8932
9437
|
}
|
|
8933
9438
|
try {
|
|
8934
|
-
if (isSymlinkPresent(skillPath))
|
|
8935
|
-
else
|
|
9439
|
+
if (isSymlinkPresent(skillPath)) fs16.unlinkSync(skillPath);
|
|
9440
|
+
else fs16.rmSync(skillPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
8936
9441
|
results.push({ tool, path: skillPath, status: "removed" });
|
|
8937
9442
|
} catch (err) {
|
|
8938
9443
|
results.push({ tool, path: skillPath, status: "error", message: err?.message ?? String(err) });
|
|
@@ -8945,7 +9450,7 @@ function uninstallSkill(toolIds, opts = {}) {
|
|
|
8945
9450
|
writeManifest({ version: 1, package: PACKAGE_NAME, entries: keep }, opts);
|
|
8946
9451
|
} else {
|
|
8947
9452
|
try {
|
|
8948
|
-
|
|
9453
|
+
fs16.rmSync(getManifestPath(opts));
|
|
8949
9454
|
} catch {
|
|
8950
9455
|
}
|
|
8951
9456
|
}
|
|
@@ -8953,7 +9458,7 @@ function uninstallSkill(toolIds, opts = {}) {
|
|
|
8953
9458
|
return results;
|
|
8954
9459
|
}
|
|
8955
9460
|
function reconcileSkillInstalls(opts = {}) {
|
|
8956
|
-
const result = { pruned: [], repointed: [] };
|
|
9461
|
+
const result = { pruned: [], repointed: [], refreshed: [] };
|
|
8957
9462
|
const manifest = readManifest(opts);
|
|
8958
9463
|
if (manifest.entries.length === 0) return result;
|
|
8959
9464
|
let source;
|
|
@@ -8962,10 +9467,68 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
8962
9467
|
} catch {
|
|
8963
9468
|
source = null;
|
|
8964
9469
|
}
|
|
9470
|
+
const currentVersion = source !== null ? readSkillVersion(source) : null;
|
|
8965
9471
|
const keep = [];
|
|
8966
9472
|
for (const e of manifest.entries) {
|
|
8967
9473
|
if (e.type !== "symlink") {
|
|
8968
|
-
|
|
9474
|
+
const stale2 = currentVersion !== null && e.version !== currentVersion;
|
|
9475
|
+
if (stale2 && source !== null && fs16.existsSync(e.path) && isOwnedCopy(e.path)) {
|
|
9476
|
+
const dir = path17.dirname(e.path);
|
|
9477
|
+
const base = path17.basename(e.path);
|
|
9478
|
+
try {
|
|
9479
|
+
for (const name of fs16.readdirSync(dir)) {
|
|
9480
|
+
if (name.startsWith(`.${base}.new-`) || name.startsWith(`.${base}.old-`)) {
|
|
9481
|
+
fs16.rmSync(path17.join(dir, name), { recursive: true, force: true });
|
|
9482
|
+
}
|
|
9483
|
+
}
|
|
9484
|
+
} catch {
|
|
9485
|
+
}
|
|
9486
|
+
const suffix = crypto10.randomBytes(4).toString("hex");
|
|
9487
|
+
const fresh = path17.join(dir, `.${base}.new-${suffix}`);
|
|
9488
|
+
const retired = path17.join(dir, `.${base}.old-${suffix}`);
|
|
9489
|
+
try {
|
|
9490
|
+
copyDir(source, fresh);
|
|
9491
|
+
} catch {
|
|
9492
|
+
try {
|
|
9493
|
+
fs16.rmSync(fresh, { recursive: true, force: true });
|
|
9494
|
+
} catch {
|
|
9495
|
+
}
|
|
9496
|
+
keep.push(e);
|
|
9497
|
+
continue;
|
|
9498
|
+
}
|
|
9499
|
+
try {
|
|
9500
|
+
fs16.renameSync(e.path, retired);
|
|
9501
|
+
} catch {
|
|
9502
|
+
try {
|
|
9503
|
+
fs16.rmSync(fresh, { recursive: true, force: true });
|
|
9504
|
+
} catch {
|
|
9505
|
+
}
|
|
9506
|
+
keep.push(e);
|
|
9507
|
+
continue;
|
|
9508
|
+
}
|
|
9509
|
+
try {
|
|
9510
|
+
fs16.renameSync(fresh, e.path);
|
|
9511
|
+
} catch {
|
|
9512
|
+
try {
|
|
9513
|
+
fs16.renameSync(retired, e.path);
|
|
9514
|
+
} catch {
|
|
9515
|
+
}
|
|
9516
|
+
try {
|
|
9517
|
+
fs16.rmSync(fresh, { recursive: true, force: true });
|
|
9518
|
+
} catch {
|
|
9519
|
+
}
|
|
9520
|
+
keep.push(e);
|
|
9521
|
+
continue;
|
|
9522
|
+
}
|
|
9523
|
+
try {
|
|
9524
|
+
fs16.rmSync(retired, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
9525
|
+
} catch {
|
|
9526
|
+
}
|
|
9527
|
+
keep.push({ ...e, source, version: currentVersion });
|
|
9528
|
+
result.refreshed.push(e.path);
|
|
9529
|
+
} else {
|
|
9530
|
+
keep.push(e);
|
|
9531
|
+
}
|
|
8969
9532
|
continue;
|
|
8970
9533
|
}
|
|
8971
9534
|
if (!isSymlinkPresent(e.path)) {
|
|
@@ -8976,18 +9539,18 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
8976
9539
|
keep.push(e);
|
|
8977
9540
|
continue;
|
|
8978
9541
|
}
|
|
8979
|
-
const targetLive =
|
|
9542
|
+
const targetLive = fs16.existsSync(e.path);
|
|
8980
9543
|
let dest = null;
|
|
8981
9544
|
try {
|
|
8982
|
-
dest = path17.resolve(path17.dirname(e.path),
|
|
9545
|
+
dest = path17.resolve(path17.dirname(e.path), fs16.readlinkSync(e.path));
|
|
8983
9546
|
} catch {
|
|
8984
9547
|
}
|
|
8985
9548
|
const stale = source !== null && dest !== null && dest !== path17.resolve(source);
|
|
8986
9549
|
if (!targetLive || stale) {
|
|
8987
9550
|
if (source !== null) {
|
|
8988
9551
|
try {
|
|
8989
|
-
|
|
8990
|
-
|
|
9552
|
+
fs16.unlinkSync(e.path);
|
|
9553
|
+
fs16.symlinkSync(source, e.path, process.platform === "win32" ? "junction" : "dir");
|
|
8991
9554
|
keep.push({ ...e, type: "symlink", source });
|
|
8992
9555
|
result.repointed.push(e.path);
|
|
8993
9556
|
} catch {
|
|
@@ -8995,7 +9558,7 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
8995
9558
|
}
|
|
8996
9559
|
} else if (!targetLive) {
|
|
8997
9560
|
try {
|
|
8998
|
-
|
|
9561
|
+
fs16.unlinkSync(e.path);
|
|
8999
9562
|
result.pruned.push(e.path);
|
|
9000
9563
|
} catch {
|
|
9001
9564
|
keep.push(e);
|
|
@@ -9010,14 +9573,22 @@ function reconcileSkillInstalls(opts = {}) {
|
|
|
9010
9573
|
if (keep.length > 0) writeManifest({ version: 1, package: PACKAGE_NAME, entries: keep }, opts);
|
|
9011
9574
|
else {
|
|
9012
9575
|
try {
|
|
9013
|
-
|
|
9576
|
+
fs16.rmSync(getManifestPath(opts));
|
|
9014
9577
|
} catch {
|
|
9015
9578
|
}
|
|
9016
9579
|
}
|
|
9017
9580
|
return result;
|
|
9018
9581
|
}
|
|
9019
9582
|
function copyDir(src, dest) {
|
|
9020
|
-
|
|
9583
|
+
const st = fs16.statSync(src);
|
|
9584
|
+
if (st.isDirectory()) {
|
|
9585
|
+
fs16.mkdirSync(dest, { recursive: true });
|
|
9586
|
+
for (const name of fs16.readdirSync(src)) {
|
|
9587
|
+
copyDir(path17.join(src, name), path17.join(dest, name));
|
|
9588
|
+
}
|
|
9589
|
+
} else {
|
|
9590
|
+
fs16.copyFileSync(src, dest);
|
|
9591
|
+
}
|
|
9021
9592
|
}
|
|
9022
9593
|
var HARNESSES, SKILL_NAME, PACKAGE_NAME, SKILL_AGENT_TARGETS, isTarget;
|
|
9023
9594
|
var init_skill_installer = __esm({
|
|
@@ -9039,7 +9610,8 @@ var init_skill_installer = __esm({
|
|
|
9039
9610
|
});
|
|
9040
9611
|
|
|
9041
9612
|
// src/cli.ts
|
|
9042
|
-
import * as
|
|
9613
|
+
import * as fs17 from "node:fs";
|
|
9614
|
+
import * as os7 from "node:os";
|
|
9043
9615
|
import * as path18 from "node:path";
|
|
9044
9616
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
9045
9617
|
|
|
@@ -9554,27 +10126,63 @@ function normalizeCitations(reports) {
|
|
|
9554
10126
|
const localKey = cit.number ?? index + 1;
|
|
9555
10127
|
localToGlobal.set(localKey, globalId);
|
|
9556
10128
|
}
|
|
9557
|
-
});
|
|
9558
|
-
const headerIdx = lastCitedLinksHeaderIndex(report);
|
|
9559
|
-
let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
|
|
9560
|
-
content =
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9565
|
-
normalizedReports.set(id, content.trim());
|
|
10129
|
+
});
|
|
10130
|
+
const headerIdx = lastCitedLinksHeaderIndex(report);
|
|
10131
|
+
let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
|
|
10132
|
+
content = rewriteCitationMarkers(
|
|
10133
|
+
content,
|
|
10134
|
+
localToGlobal,
|
|
10135
|
+
(n) => !localToGlobal.has(n)
|
|
10136
|
+
);
|
|
10137
|
+
normalizedReports.set(id, content.trim());
|
|
10138
|
+
}
|
|
10139
|
+
return { normalizedReports, globalCitations };
|
|
10140
|
+
}
|
|
10141
|
+
function formatCitedLinks(citations) {
|
|
10142
|
+
if (citations.length === 0) return "";
|
|
10143
|
+
const links = citations.map((cit) => {
|
|
10144
|
+
const sourcePart = cit.source ? ` [Source: ${cit.source}]` : "";
|
|
10145
|
+
const descPart = cit.description ? ` \u2014 ${cit.description}` : "";
|
|
10146
|
+
return `[${cit.id}] ${cit.url}${sourcePart}${descPart}`;
|
|
10147
|
+
});
|
|
10148
|
+
return `CITED LINKS
|
|
10149
|
+
${links.join("\n")}`;
|
|
10150
|
+
}
|
|
10151
|
+
var MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
10152
|
+
function rewriteCitationMarkers(text, remap, isDangling) {
|
|
10153
|
+
const inFence = fencedCodeMask(text);
|
|
10154
|
+
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
|
|
10155
|
+
const n = parseInt(digits, 10);
|
|
10156
|
+
const mapped = remap.get(n);
|
|
10157
|
+
if (mapped !== void 0) return `${pad}[${mapped}]`;
|
|
10158
|
+
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
10159
|
+
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
10160
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
10161
|
+
return text.slice(lineStart, offset).trim() === "" ? pad : "";
|
|
10162
|
+
});
|
|
10163
|
+
}
|
|
10164
|
+
function fencedCodeMask(text) {
|
|
10165
|
+
const ranges = [];
|
|
10166
|
+
let open5 = -1;
|
|
10167
|
+
let pos = 0;
|
|
10168
|
+
for (const line of text.split("\n")) {
|
|
10169
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
10170
|
+
if (open5 < 0) open5 = pos;
|
|
10171
|
+
else {
|
|
10172
|
+
ranges.push([open5, pos + line.length]);
|
|
10173
|
+
open5 = -1;
|
|
10174
|
+
}
|
|
10175
|
+
}
|
|
10176
|
+
pos += line.length + 1;
|
|
9566
10177
|
}
|
|
9567
|
-
|
|
10178
|
+
if (open5 >= 0) ranges.push([open5, text.length]);
|
|
10179
|
+
return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
9568
10180
|
}
|
|
9569
|
-
function
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
return `[${cit.id}] ${cit.url}${sourcePart}${descPart}`;
|
|
9575
|
-
});
|
|
9576
|
-
return `CITED LINKS
|
|
9577
|
-
${links.join("\n")}`;
|
|
10181
|
+
function inInlineCodeSpan(text, offset) {
|
|
10182
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
10183
|
+
let ticks = 0;
|
|
10184
|
+
for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
|
|
10185
|
+
return ticks % 2 === 1;
|
|
9578
10186
|
}
|
|
9579
10187
|
|
|
9580
10188
|
// src/core/planning-service.ts
|
|
@@ -10463,11 +11071,11 @@ import * as path7 from "node:path";
|
|
|
10463
11071
|
init_config();
|
|
10464
11072
|
init_logger();
|
|
10465
11073
|
import * as crypto2 from "node:crypto";
|
|
10466
|
-
import { join as
|
|
11074
|
+
import { join as join7 } from "node:path";
|
|
10467
11075
|
import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "node:fs";
|
|
10468
11076
|
import { platform, homedir as homedir2 } from "node:os";
|
|
10469
11077
|
function getWindowsCamoufoxDir() {
|
|
10470
|
-
return
|
|
11078
|
+
return join7(homedir2(), "AppData", "Local", "camoufox", "camoufox", "Cache");
|
|
10471
11079
|
}
|
|
10472
11080
|
function getBrowserCacheDir() {
|
|
10473
11081
|
if (process.env["PLAYWRIGHT_BROWSERS_PATH"]) {
|
|
@@ -10477,10 +11085,10 @@ function getBrowserCacheDir() {
|
|
|
10477
11085
|
if (osPlatform === "win32") {
|
|
10478
11086
|
return getWindowsCamoufoxDir();
|
|
10479
11087
|
} else if (osPlatform === "darwin") {
|
|
10480
|
-
return
|
|
11088
|
+
return join7(homedir2(), "Library", "Caches", "camoufox");
|
|
10481
11089
|
} else {
|
|
10482
|
-
const cacheHome = process.env["XDG_CACHE_HOME"] ||
|
|
10483
|
-
return
|
|
11090
|
+
const cacheHome = process.env["XDG_CACHE_HOME"] || join7(homedir2(), ".cache");
|
|
11091
|
+
return join7(cacheHome, "camoufox");
|
|
10484
11092
|
}
|
|
10485
11093
|
}
|
|
10486
11094
|
function getBrowserProfileDir(config) {
|
|
@@ -10489,8 +11097,8 @@ function getBrowserProfileDir(config) {
|
|
|
10489
11097
|
if (configured && configured.trim().length > 0) {
|
|
10490
11098
|
dir = configured;
|
|
10491
11099
|
} else {
|
|
10492
|
-
const cacheHome = process.env["XDG_CACHE_HOME"] ||
|
|
10493
|
-
dir =
|
|
11100
|
+
const cacheHome = process.env["XDG_CACHE_HOME"] || join7(homedir2(), ".cache");
|
|
11101
|
+
dir = join7(cacheHome, "pi-research", "profiles");
|
|
10494
11102
|
}
|
|
10495
11103
|
if (!existsSync5(dir)) {
|
|
10496
11104
|
try {
|
|
@@ -10543,10 +11151,10 @@ function getCamoufoxBinaryPath() {
|
|
|
10543
11151
|
if (osPlatform === "win32") {
|
|
10544
11152
|
return getWindowsCamoufoxDir();
|
|
10545
11153
|
} else if (osPlatform === "darwin") {
|
|
10546
|
-
return
|
|
11154
|
+
return join7(homedir2(), "Library", "Caches", "camoufox");
|
|
10547
11155
|
} else {
|
|
10548
|
-
const cacheHome = process.env["XDG_CACHE_HOME"] ||
|
|
10549
|
-
return
|
|
11156
|
+
const cacheHome = process.env["XDG_CACHE_HOME"] || join7(homedir2(), ".cache");
|
|
11157
|
+
return join7(cacheHome, "camoufox");
|
|
10550
11158
|
}
|
|
10551
11159
|
}
|
|
10552
11160
|
function generateSchedulerVersion(config) {
|
|
@@ -10584,6 +11192,7 @@ function isFullMockMode() {
|
|
|
10584
11192
|
// src/infrastructure/browser/browser-client.ts
|
|
10585
11193
|
init_logger();
|
|
10586
11194
|
init_error_tracker();
|
|
11195
|
+
init_http_body();
|
|
10587
11196
|
import * as http2 from "node:http";
|
|
10588
11197
|
|
|
10589
11198
|
// src/infrastructure/browser/client-agent.ts
|
|
@@ -10593,7 +11202,7 @@ var clientAgent = new http.Agent({
|
|
|
10593
11202
|
maxSockets: 100,
|
|
10594
11203
|
// Allow up to 100 concurrent requests to the leader
|
|
10595
11204
|
maxFreeSockets: 10,
|
|
10596
|
-
//
|
|
11205
|
+
// 180s (3x the client timeout) to handle slow browser responses
|
|
10597
11206
|
// and prevent "socket hang up" errors during peak load
|
|
10598
11207
|
timeout: 18e4
|
|
10599
11208
|
});
|
|
@@ -10610,6 +11219,12 @@ var BrowserClient = class {
|
|
|
10610
11219
|
}
|
|
10611
11220
|
port;
|
|
10612
11221
|
authSecret;
|
|
11222
|
+
/** The leader port this client was built for — lets the herd-guard cooldown
|
|
11223
|
+
* verify the cached handle still targets the REGISTERED leader, not a
|
|
11224
|
+
* predecessor that died inside the cooldown window. */
|
|
11225
|
+
get targetPort() {
|
|
11226
|
+
return this.port;
|
|
11227
|
+
}
|
|
10613
11228
|
async request(path19, data, signal) {
|
|
10614
11229
|
const start = Date.now();
|
|
10615
11230
|
const operation = path19.includes("/search") ? "search" : path19.includes("/scrape") ? "browser-task" : path19.includes("/healthcheck") ? "healthcheck" : "network";
|
|
@@ -10660,8 +11275,8 @@ var BrowserClient = class {
|
|
|
10660
11275
|
"X-Browser-Auth": this.authSecret
|
|
10661
11276
|
}
|
|
10662
11277
|
}, (res) => {
|
|
10663
|
-
|
|
10664
|
-
res.on("data", (chunk) =>
|
|
11278
|
+
const collected = new Utf8Body();
|
|
11279
|
+
res.on("data", (chunk) => collected.push(chunk));
|
|
10665
11280
|
res.on("end", () => {
|
|
10666
11281
|
if (resolved) return;
|
|
10667
11282
|
resolved = true;
|
|
@@ -10669,7 +11284,7 @@ var BrowserClient = class {
|
|
|
10669
11284
|
abortCleanup?.();
|
|
10670
11285
|
const duration = Date.now() - start;
|
|
10671
11286
|
try {
|
|
10672
|
-
const parsed = JSON.parse(
|
|
11287
|
+
const parsed = JSON.parse(collected.toString());
|
|
10673
11288
|
if (res.statusCode === 503 && parsed?.error === "draining") {
|
|
10674
11289
|
const error = new Error(
|
|
10675
11290
|
`Browser pool leader is draining (${parsed.reason || "leadership-lost"}) \u2014 re-elect and retry`
|
|
@@ -10694,7 +11309,8 @@ var BrowserClient = class {
|
|
|
10694
11309
|
resolve8(parsed);
|
|
10695
11310
|
}
|
|
10696
11311
|
} catch (_e) {
|
|
10697
|
-
const
|
|
11312
|
+
const raw = collected.toString();
|
|
11313
|
+
const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
|
|
10698
11314
|
const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
|
|
10699
11315
|
errorTracker.trackError(error, {
|
|
10700
11316
|
component: "browser-manager",
|
|
@@ -10776,6 +11392,7 @@ init_service_interfaces();
|
|
|
10776
11392
|
|
|
10777
11393
|
// src/infrastructure/browser/browser-server.ts
|
|
10778
11394
|
init_logger();
|
|
11395
|
+
init_http_body();
|
|
10779
11396
|
import * as http3 from "node:http";
|
|
10780
11397
|
import * as crypto3 from "node:crypto";
|
|
10781
11398
|
|
|
@@ -10813,7 +11430,16 @@ function isServerDrainingError(error) {
|
|
|
10813
11430
|
function isPoolShutdownError(error) {
|
|
10814
11431
|
if (!error || typeof error !== "object") return false;
|
|
10815
11432
|
const err = error;
|
|
10816
|
-
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool")
|
|
11433
|
+
return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
|
|
11434
|
+
// pool. Its own text says "please retry", but until it was listed here no
|
|
11435
|
+
// classifier matched it, so the first attempt rethrew instead of retrying —
|
|
11436
|
+
// and auto-recovery fires only after 3 consecutive worker failures, i.e.
|
|
11437
|
+
// exactly when a burst of tasks is in flight and would all die together.
|
|
11438
|
+
err.message.includes("Worker pool is being reset") || // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
|
|
11439
|
+
// while it is in progress). A queued closure holding a pool captured before
|
|
11440
|
+
// an auto-recovery destroy dispatches into exactly this window; it is the
|
|
11441
|
+
// same transient swap the "being reset" string covers, not a hard fault.
|
|
11442
|
+
err.message.includes("Cannot execute a task on not started pool"));
|
|
10817
11443
|
}
|
|
10818
11444
|
var sessionCircuitBreakers = /* @__PURE__ */ new Map();
|
|
10819
11445
|
var MAX_SESSION_BREAKERS = 256;
|
|
@@ -10921,11 +11547,13 @@ var BrowserServer = class {
|
|
|
10921
11547
|
const expected = Buffer.from(getOrCreateAuthSecret(), "utf8");
|
|
10922
11548
|
const actual = Buffer.from(authHeader ?? "", "utf8");
|
|
10923
11549
|
if (actual.length !== expected.length || !crypto3.timingSafeEqual(actual, expected)) {
|
|
11550
|
+
req.resume();
|
|
10924
11551
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
10925
11552
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
10926
11553
|
return;
|
|
10927
11554
|
}
|
|
10928
11555
|
if (this.draining) {
|
|
11556
|
+
req.resume();
|
|
10929
11557
|
res.writeHead(503, {
|
|
10930
11558
|
"Content-Type": "application/json",
|
|
10931
11559
|
"Connection": "close"
|
|
@@ -10934,16 +11562,17 @@ var BrowserServer = class {
|
|
|
10934
11562
|
return;
|
|
10935
11563
|
}
|
|
10936
11564
|
if (req.method !== "POST") {
|
|
11565
|
+
req.resume();
|
|
10937
11566
|
res.writeHead(405);
|
|
10938
11567
|
res.end("Method Not Allowed");
|
|
10939
11568
|
return;
|
|
10940
11569
|
}
|
|
10941
|
-
|
|
11570
|
+
const collected = new Utf8Body();
|
|
10942
11571
|
let oversized = false;
|
|
10943
11572
|
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
10944
11573
|
req.on("data", (chunk) => {
|
|
10945
|
-
|
|
10946
|
-
if (
|
|
11574
|
+
collected.push(chunk);
|
|
11575
|
+
if (collected.byteLength > MAX_BODY_SIZE) {
|
|
10947
11576
|
oversized = true;
|
|
10948
11577
|
req.destroy(new Error("Payload too large"));
|
|
10949
11578
|
}
|
|
@@ -10960,7 +11589,7 @@ var BrowserServer = class {
|
|
|
10960
11589
|
});
|
|
10961
11590
|
req.on("end", async () => {
|
|
10962
11591
|
try {
|
|
10963
|
-
const data = JSON.parse(
|
|
11592
|
+
const data = JSON.parse(collected.toString());
|
|
10964
11593
|
let result;
|
|
10965
11594
|
switch (req.url) {
|
|
10966
11595
|
case "/search":
|
|
@@ -11339,10 +11968,10 @@ var PriorityTaskQueue = class {
|
|
|
11339
11968
|
this.activeCount++;
|
|
11340
11969
|
logger.debug(`[PriorityQueue] Starting task: ${task.type}. Active: ${this.activeCount}/${this.maxTotalConcurrency}`);
|
|
11341
11970
|
let onAbort;
|
|
11342
|
-
const fnPromise = task.fn();
|
|
11343
|
-
fnPromise.catch(() => {
|
|
11344
|
-
});
|
|
11345
11971
|
try {
|
|
11972
|
+
const fnPromise = task.fn();
|
|
11973
|
+
fnPromise.catch(() => {
|
|
11974
|
+
});
|
|
11346
11975
|
let result;
|
|
11347
11976
|
if (task.signal) {
|
|
11348
11977
|
const abortPromise = new Promise((_, rej) => {
|
|
@@ -11408,6 +12037,25 @@ var PriorityTaskQueue = class {
|
|
|
11408
12037
|
};
|
|
11409
12038
|
|
|
11410
12039
|
// src/infrastructure/browser/browser-task-scheduler.ts
|
|
12040
|
+
var WORKER_DEATH_BACKSTOP_GRACE_MS = 5e3;
|
|
12041
|
+
async function executePoolTaskWithDeathBackstop(execute, taskTimeoutMs, label) {
|
|
12042
|
+
const backstopMs = taskTimeoutMs + WORKER_DEATH_BACKSTOP_GRACE_MS;
|
|
12043
|
+
let backstopId;
|
|
12044
|
+
try {
|
|
12045
|
+
return await Promise.race([
|
|
12046
|
+
execute(),
|
|
12047
|
+
new Promise((_, reject) => {
|
|
12048
|
+
backstopId = setTimeout(
|
|
12049
|
+
() => reject(new Error(`${label} task unanswered after ${backstopMs}ms \u2014 worker likely died mid-task`)),
|
|
12050
|
+
backstopMs
|
|
12051
|
+
);
|
|
12052
|
+
if (backstopId.unref) backstopId.unref();
|
|
12053
|
+
})
|
|
12054
|
+
]);
|
|
12055
|
+
} finally {
|
|
12056
|
+
if (backstopId) clearTimeout(backstopId);
|
|
12057
|
+
}
|
|
12058
|
+
}
|
|
11411
12059
|
var BrowserTaskScheduler = class {
|
|
11412
12060
|
// 5 minutes (was 30min — wasted RAM)
|
|
11413
12061
|
constructor(schedulerId, stateManager, container = getServiceContainer()) {
|
|
@@ -11535,15 +12183,18 @@ var BrowserTaskScheduler = class {
|
|
|
11535
12183
|
});
|
|
11536
12184
|
logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
|
|
11537
12185
|
let result;
|
|
12186
|
+
const settled = new AbortController();
|
|
12187
|
+
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
11538
12188
|
try {
|
|
11539
12189
|
const queue = this.getPriorityQueue(config);
|
|
11540
12190
|
result = await Promise.race([
|
|
11541
12191
|
queue.enqueue("search", async () => {
|
|
11542
|
-
return await
|
|
11543
|
-
pool.execute({ type: "search", query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
11544
|
-
|
|
11545
|
-
|
|
11546
|
-
|
|
12192
|
+
return await executePoolTaskWithDeathBackstop(
|
|
12193
|
+
() => pool.execute({ type: "search", query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
12194
|
+
timeoutMs,
|
|
12195
|
+
"Search"
|
|
12196
|
+
);
|
|
12197
|
+
}, queueSignal),
|
|
11547
12198
|
timeoutPromise
|
|
11548
12199
|
]);
|
|
11549
12200
|
logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${Date.now() - startTime}ms`);
|
|
@@ -11563,6 +12214,7 @@ var BrowserTaskScheduler = class {
|
|
|
11563
12214
|
throw error;
|
|
11564
12215
|
} finally {
|
|
11565
12216
|
if (timeoutId) clearTimeout(timeoutId);
|
|
12217
|
+
settled.abort();
|
|
11566
12218
|
}
|
|
11567
12219
|
const duration = Date.now() - startTime;
|
|
11568
12220
|
metrics.observe("browser_search_duration_ms", duration, { status: "success" });
|
|
@@ -11596,15 +12248,18 @@ var BrowserTaskScheduler = class {
|
|
|
11596
12248
|
if (timeoutId.unref) timeoutId.unref();
|
|
11597
12249
|
});
|
|
11598
12250
|
let result;
|
|
12251
|
+
const settled = new AbortController();
|
|
12252
|
+
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
11599
12253
|
try {
|
|
11600
12254
|
const queue = this.getPriorityQueue(config);
|
|
11601
12255
|
result = await Promise.race([
|
|
11602
12256
|
queue.enqueue("scrape", async () => {
|
|
11603
|
-
return await
|
|
11604
|
-
pool.execute({ type: "scrape", url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
11605
|
-
|
|
11606
|
-
|
|
11607
|
-
|
|
12257
|
+
return await executePoolTaskWithDeathBackstop(
|
|
12258
|
+
() => pool.execute({ type: "scrape", url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
12259
|
+
timeoutMs,
|
|
12260
|
+
"Scrape"
|
|
12261
|
+
);
|
|
12262
|
+
}, queueSignal),
|
|
11608
12263
|
timeoutPromise
|
|
11609
12264
|
]);
|
|
11610
12265
|
} catch (error) {
|
|
@@ -11621,6 +12276,7 @@ var BrowserTaskScheduler = class {
|
|
|
11621
12276
|
throw error;
|
|
11622
12277
|
} finally {
|
|
11623
12278
|
if (timeoutId) clearTimeout(timeoutId);
|
|
12279
|
+
settled.abort();
|
|
11624
12280
|
}
|
|
11625
12281
|
const duration = Date.now() - startTime;
|
|
11626
12282
|
metrics.observe("browser_scrape_duration_ms", duration, { status: "success" });
|
|
@@ -11650,17 +12306,18 @@ var BrowserTaskScheduler = class {
|
|
|
11650
12306
|
if (timeoutId.unref) timeoutId.unref();
|
|
11651
12307
|
});
|
|
11652
12308
|
let result;
|
|
12309
|
+
const settled = new AbortController();
|
|
12310
|
+
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
11653
12311
|
try {
|
|
11654
12312
|
const queue = this.getPriorityQueue(config);
|
|
11655
12313
|
result = await Promise.race([
|
|
11656
12314
|
queue.enqueue("healthcheck", async () => {
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
execPromise
|
|
11661
|
-
|
|
11662
|
-
|
|
11663
|
-
}, signal),
|
|
12315
|
+
return await executePoolTaskWithDeathBackstop(() => {
|
|
12316
|
+
const execPromise = pool.execute({ type: "healthcheck", queuedAt: startTime, taskTimeoutMs: timeoutMs });
|
|
12317
|
+
execPromise.catch((err) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
|
|
12318
|
+
return execPromise;
|
|
12319
|
+
}, timeoutMs, "Healthcheck");
|
|
12320
|
+
}, queueSignal),
|
|
11664
12321
|
timeoutPromise
|
|
11665
12322
|
]);
|
|
11666
12323
|
} catch (error) {
|
|
@@ -11677,6 +12334,7 @@ var BrowserTaskScheduler = class {
|
|
|
11677
12334
|
throw error;
|
|
11678
12335
|
} finally {
|
|
11679
12336
|
if (timeoutId) clearTimeout(timeoutId);
|
|
12337
|
+
settled.abort();
|
|
11680
12338
|
}
|
|
11681
12339
|
const duration = Date.now() - startTime;
|
|
11682
12340
|
metrics.observe("browser_healthcheck_duration_ms", duration, { status: "success" });
|
|
@@ -11717,12 +12375,16 @@ var BrowserTaskScheduler = class {
|
|
|
11717
12375
|
this.leadershipTimer = null;
|
|
11718
12376
|
}
|
|
11719
12377
|
if (!this.container.isDisposing) {
|
|
11720
|
-
|
|
11721
|
-
|
|
11722
|
-
|
|
11723
|
-
|
|
11724
|
-
|
|
11725
|
-
|
|
12378
|
+
try {
|
|
12379
|
+
const schedulerService = await getService(ServiceNames.SCHEDULER, void 0, this.container);
|
|
12380
|
+
const currentScheduler = schedulerService.getSchedulerInstance();
|
|
12381
|
+
if (currentScheduler && "schedulerId" in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
|
|
12382
|
+
schedulerService.setSchedulerInstance(null);
|
|
12383
|
+
schedulerService.setSchedulerVersion(null);
|
|
12384
|
+
schedulerService.setSchedulerInitializationPromise(null);
|
|
12385
|
+
}
|
|
12386
|
+
} catch (err) {
|
|
12387
|
+
logger.debug("[Scheduler] Could not clear scheduler service reference during shutdown:", err);
|
|
11726
12388
|
}
|
|
11727
12389
|
}
|
|
11728
12390
|
let serverInfo = null;
|
|
@@ -11968,6 +12630,15 @@ async function getScheduler(config, container = getServiceContainer()) {
|
|
|
11968
12630
|
let wonElection = false;
|
|
11969
12631
|
let winnerPort = port;
|
|
11970
12632
|
let winnerAuthSecret;
|
|
12633
|
+
const myStartTime = await (async () => {
|
|
12634
|
+
try {
|
|
12635
|
+
const processLifecycle = await getService(ServiceNames.PROCESS_LIFECYCLE, void 0, container);
|
|
12636
|
+
return await processLifecycle.getCurrentProcessStartTime();
|
|
12637
|
+
} catch (err) {
|
|
12638
|
+
logger.debug("[Scheduler] Could not read own process start time (PID-reuse guard degrades to signal-0):", err);
|
|
12639
|
+
return null;
|
|
12640
|
+
}
|
|
12641
|
+
})();
|
|
11971
12642
|
try {
|
|
11972
12643
|
await stateManager.updateState(async (state) => {
|
|
11973
12644
|
if (state.browserServer) {
|
|
@@ -11980,7 +12651,13 @@ async function getScheduler(config, container = getServiceContainer()) {
|
|
|
11980
12651
|
}
|
|
11981
12652
|
}
|
|
11982
12653
|
const secret = getBrowserServerAuthSecret();
|
|
11983
|
-
state.browserServer = {
|
|
12654
|
+
state.browserServer = {
|
|
12655
|
+
port,
|
|
12656
|
+
pid: process.pid,
|
|
12657
|
+
schedulerId,
|
|
12658
|
+
authSecret: secret,
|
|
12659
|
+
...myStartTime !== null ? { startTime: myStartTime } : {}
|
|
12660
|
+
};
|
|
11984
12661
|
state.schedulerVersion = schedulerVersion;
|
|
11985
12662
|
wonElection = true;
|
|
11986
12663
|
return state;
|
|
@@ -12078,8 +12755,9 @@ init_service_interfaces();
|
|
|
12078
12755
|
// src/infrastructure/state/state-manager.ts
|
|
12079
12756
|
init_config();
|
|
12080
12757
|
init_logger();
|
|
12758
|
+
init_atomic_replace();
|
|
12081
12759
|
init_metrics();
|
|
12082
|
-
import * as
|
|
12760
|
+
import * as fs8 from "node:fs/promises";
|
|
12083
12761
|
import * as path8 from "node:path";
|
|
12084
12762
|
import * as crypto5 from "node:crypto";
|
|
12085
12763
|
import process2 from "node:process";
|
|
@@ -12130,7 +12808,16 @@ var SingletonStateSchema = Type3.Object({
|
|
|
12130
12808
|
// cross-embed and corrupt the vector space) — it falls back to a local
|
|
12131
12809
|
// in-process embedder instead. Optional for entries written by older
|
|
12132
12810
|
// processes, which are tolerated as matching.
|
|
12133
|
-
model: Type3.Optional(Type3.String())
|
|
12811
|
+
model: Type3.Optional(Type3.String()),
|
|
12812
|
+
// Shared secret a follower must present on every embed request. Mirrors
|
|
12813
|
+
// browserServer.authSecret: the server listens on loopback, but loopback is
|
|
12814
|
+
// not an authorization boundary — any local process could otherwise enqueue
|
|
12815
|
+
// embedding work, and because the serial queue steps the leader down when
|
|
12816
|
+
// poisoned, sustained abuse denies the knowledge store rather than merely
|
|
12817
|
+
// burning GPU. Optional so an entry written by an older leader still parses;
|
|
12818
|
+
// a client that finds none simply sends no header, and such a leader does not
|
|
12819
|
+
// check one either.
|
|
12820
|
+
authSecret: Type3.Optional(Type3.String())
|
|
12134
12821
|
}))
|
|
12135
12822
|
});
|
|
12136
12823
|
|
|
@@ -12347,7 +13034,7 @@ var StateManager = class {
|
|
|
12347
13034
|
];
|
|
12348
13035
|
for (const dir of dirs) {
|
|
12349
13036
|
try {
|
|
12350
|
-
await
|
|
13037
|
+
await fs8.mkdir(dir, { recursive: true, mode: 448 });
|
|
12351
13038
|
} catch (err) {
|
|
12352
13039
|
logger.error(`[StateManager] Failed to create directory ${dir}:`, err);
|
|
12353
13040
|
throw err;
|
|
@@ -12377,14 +13064,14 @@ var StateManager = class {
|
|
|
12377
13064
|
const stateDir = path8.dirname(this.stateFilePath);
|
|
12378
13065
|
const cutoff = Date.now() - STALE_TMP_MS;
|
|
12379
13066
|
try {
|
|
12380
|
-
const entries = await
|
|
13067
|
+
const entries = await fs8.readdir(stateDir);
|
|
12381
13068
|
for (const entry of entries) {
|
|
12382
13069
|
if (!entry.startsWith("research-state-") || !entry.endsWith(".tmp")) continue;
|
|
12383
13070
|
const filePath = path8.join(stateDir, entry);
|
|
12384
13071
|
try {
|
|
12385
|
-
const stats = await
|
|
13072
|
+
const stats = await fs8.stat(filePath);
|
|
12386
13073
|
if (!stats.isFile() || stats.mtimeMs >= cutoff) continue;
|
|
12387
|
-
await
|
|
13074
|
+
await fs8.unlink(filePath);
|
|
12388
13075
|
logger.debug(`[StateManager] Removed orphaned state temp file: ${entry}`);
|
|
12389
13076
|
} catch {
|
|
12390
13077
|
}
|
|
@@ -12415,7 +13102,7 @@ var StateManager = class {
|
|
|
12415
13102
|
return this.getDefaultState();
|
|
12416
13103
|
}
|
|
12417
13104
|
try {
|
|
12418
|
-
const content = await
|
|
13105
|
+
const content = await fs8.readFile(this.stateFilePath, "utf-8");
|
|
12419
13106
|
const state = JSON.parse(content);
|
|
12420
13107
|
return this.validator.migrateAndValidate(state);
|
|
12421
13108
|
} catch (error) {
|
|
@@ -12444,7 +13131,7 @@ var StateManager = class {
|
|
|
12444
13131
|
);
|
|
12445
13132
|
try {
|
|
12446
13133
|
await this.recoverFromCorruptionDirect();
|
|
12447
|
-
const recovered = await
|
|
13134
|
+
const recovered = await fs8.readFile(this.stateFilePath, "utf-8");
|
|
12448
13135
|
return this.validator.migrateAndValidate(JSON.parse(recovered));
|
|
12449
13136
|
} catch (recoveryError) {
|
|
12450
13137
|
logger.error(
|
|
@@ -12505,26 +13192,24 @@ var StateManager = class {
|
|
|
12505
13192
|
const tempFileName = `research-state-${crypto5.randomBytes(16).toString("hex")}.tmp`;
|
|
12506
13193
|
tempFilePath = path8.join(path8.dirname(this.stateFilePath), tempFileName);
|
|
12507
13194
|
const content = JSON.stringify(state, null, 2);
|
|
12508
|
-
const fh = await
|
|
13195
|
+
const fh = await fs8.open(tempFilePath, "w", 384);
|
|
12509
13196
|
try {
|
|
12510
13197
|
await fh.writeFile(content, { encoding: "utf-8" });
|
|
12511
13198
|
await fh.sync();
|
|
12512
13199
|
} finally {
|
|
12513
13200
|
await fh.close();
|
|
12514
13201
|
}
|
|
12515
|
-
|
|
12516
|
-
|
|
12517
|
-
|
|
12518
|
-
|
|
12519
|
-
|
|
12520
|
-
|
|
12521
|
-
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
12525
|
-
}
|
|
12526
|
-
throw renameErr;
|
|
12527
|
-
}
|
|
13202
|
+
const outcome = await replaceFile(tempFilePath, this.stateFilePath);
|
|
13203
|
+
if (outcome === "copied") {
|
|
13204
|
+
logger.warn(
|
|
13205
|
+
"[StateManager] State written via non-atomic copy: the rename was blocked by another process holding the state file. A concurrent reader may observe a partial file."
|
|
13206
|
+
);
|
|
13207
|
+
metrics.increment("state_write_non_atomic_total", 1);
|
|
13208
|
+
const tmp = tempFilePath;
|
|
13209
|
+
tempFilePath = null;
|
|
13210
|
+
await fs8.unlink(tmp).catch((err) => {
|
|
13211
|
+
logger.warn("[StateManager] Failed to remove temp file after copy fallback (write succeeded):", err);
|
|
13212
|
+
});
|
|
12528
13213
|
}
|
|
12529
13214
|
tempFilePath = null;
|
|
12530
13215
|
await this.backupManager.cleanupOldBackups();
|
|
@@ -12532,7 +13217,7 @@ var StateManager = class {
|
|
|
12532
13217
|
throw new Error(`Failed to write state: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
12533
13218
|
} finally {
|
|
12534
13219
|
if (tempFilePath) {
|
|
12535
|
-
await
|
|
13220
|
+
await fs8.unlink(tempFilePath).catch((err) => {
|
|
12536
13221
|
logger.warn("[StateManager] Failed to clean up temp file:", err);
|
|
12537
13222
|
});
|
|
12538
13223
|
}
|
|
@@ -12962,8 +13647,23 @@ var MetricsService = class {
|
|
|
12962
13647
|
// src/infrastructure/process-lifecycle-service.ts
|
|
12963
13648
|
init_service_registry();
|
|
12964
13649
|
init_service_names();
|
|
12965
|
-
import * as
|
|
13650
|
+
import * as fs9 from "node:fs/promises";
|
|
12966
13651
|
import { execFile } from "node:child_process";
|
|
13652
|
+
function parseEtime(value) {
|
|
13653
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(value.trim());
|
|
13654
|
+
if (!m) return null;
|
|
13655
|
+
const days = m[1] ? parseInt(m[1], 10) : 0;
|
|
13656
|
+
const hours = m[2] ? parseInt(m[2], 10) : 0;
|
|
13657
|
+
const minutes = parseInt(m[3], 10);
|
|
13658
|
+
const seconds = parseInt(m[4], 10);
|
|
13659
|
+
if ([days, hours, minutes, seconds].some((n) => isNaN(n))) return null;
|
|
13660
|
+
return days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
13661
|
+
}
|
|
13662
|
+
function isDefinitelyGone(err) {
|
|
13663
|
+
const code = err?.code;
|
|
13664
|
+
if (code === "EPERM" || code === "EACCES") return false;
|
|
13665
|
+
return true;
|
|
13666
|
+
}
|
|
12967
13667
|
var ProcessLifecycleService = class {
|
|
12968
13668
|
name = ServiceNames.PROCESS_LIFECYCLE;
|
|
12969
13669
|
lifecycle = "uninitialized" /* UNINITIALIZED */;
|
|
@@ -12984,7 +13684,7 @@ var ProcessLifecycleService = class {
|
|
|
12984
13684
|
async getProcessStartTime(pid) {
|
|
12985
13685
|
if (process.platform === "linux") {
|
|
12986
13686
|
try {
|
|
12987
|
-
const stat5 = await
|
|
13687
|
+
const stat5 = await fs9.readFile(`/proc/${pid}/stat`, "utf8");
|
|
12988
13688
|
const lastParenIndex = stat5.lastIndexOf(")");
|
|
12989
13689
|
if (lastParenIndex === -1) return null;
|
|
12990
13690
|
const partsAfterName = stat5.substring(lastParenIndex + 2).trim().split(/\s+/);
|
|
@@ -13012,23 +13712,35 @@ var ProcessLifecycleService = class {
|
|
|
13012
13712
|
return null;
|
|
13013
13713
|
}
|
|
13014
13714
|
}
|
|
13015
|
-
|
|
13016
|
-
|
|
13017
|
-
|
|
13018
|
-
|
|
13019
|
-
|
|
13020
|
-
|
|
13021
|
-
|
|
13022
|
-
|
|
13023
|
-
|
|
13024
|
-
|
|
13025
|
-
|
|
13026
|
-
|
|
13027
|
-
|
|
13028
|
-
|
|
13029
|
-
|
|
13030
|
-
|
|
13715
|
+
const elapsedSec = await this.readElapsedSeconds(pid);
|
|
13716
|
+
if (elapsedSec === null) return null;
|
|
13717
|
+
return Math.floor(Date.now() / 1e3) - elapsedSec;
|
|
13718
|
+
}
|
|
13719
|
+
/** Elapsed seconds for `pid` via ps, or null. Tries `etimes`, then POSIX `etime`. */
|
|
13720
|
+
async readElapsedSeconds(pid) {
|
|
13721
|
+
const run = async (fmt) => {
|
|
13722
|
+
try {
|
|
13723
|
+
return await new Promise((resolve8, reject) => {
|
|
13724
|
+
const child = execFile(
|
|
13725
|
+
"ps",
|
|
13726
|
+
["-o", fmt, "-p", String(pid)],
|
|
13727
|
+
{ encoding: "utf8", timeout: 3e3 },
|
|
13728
|
+
(err, stdout) => err ? reject(err) : resolve8(stdout)
|
|
13729
|
+
);
|
|
13730
|
+
child.unref?.();
|
|
13731
|
+
});
|
|
13732
|
+
} catch {
|
|
13733
|
+
return null;
|
|
13734
|
+
}
|
|
13735
|
+
};
|
|
13736
|
+
const etimes = await run("etimes=");
|
|
13737
|
+
if (etimes && etimes.trim()) {
|
|
13738
|
+
const n = parseInt(etimes.trim(), 10);
|
|
13739
|
+
if (!isNaN(n)) return n;
|
|
13031
13740
|
}
|
|
13741
|
+
const etime = await run("etime=");
|
|
13742
|
+
if (etime && etime.trim()) return parseEtime(etime.trim());
|
|
13743
|
+
return null;
|
|
13032
13744
|
}
|
|
13033
13745
|
/**
|
|
13034
13746
|
* Get the start time for the current process
|
|
@@ -13049,17 +13761,25 @@ var ProcessLifecycleService = class {
|
|
|
13049
13761
|
async isProcessAlive(pid, expectedStartTime) {
|
|
13050
13762
|
try {
|
|
13051
13763
|
process.kill(pid, 0);
|
|
13764
|
+
} catch (err) {
|
|
13765
|
+
if (isDefinitelyGone(err)) return false;
|
|
13766
|
+
}
|
|
13767
|
+
try {
|
|
13052
13768
|
if (expectedStartTime !== void 0 && expectedStartTime !== null) {
|
|
13053
13769
|
const actualStartTime = await this.getProcessStartTime(pid);
|
|
13054
13770
|
if (actualStartTime === null) {
|
|
13055
|
-
|
|
13056
|
-
|
|
13771
|
+
try {
|
|
13772
|
+
process.kill(pid, 0);
|
|
13773
|
+
return true;
|
|
13774
|
+
} catch (reconfirmErr) {
|
|
13775
|
+
return !isDefinitelyGone(reconfirmErr);
|
|
13776
|
+
}
|
|
13057
13777
|
}
|
|
13058
13778
|
return Math.abs(actualStartTime - expectedStartTime) <= 1;
|
|
13059
13779
|
}
|
|
13060
13780
|
return true;
|
|
13061
13781
|
} catch {
|
|
13062
|
-
return
|
|
13782
|
+
return true;
|
|
13063
13783
|
}
|
|
13064
13784
|
}
|
|
13065
13785
|
/**
|
|
@@ -13069,8 +13789,8 @@ var ProcessLifecycleService = class {
|
|
|
13069
13789
|
try {
|
|
13070
13790
|
process.kill(pid, 0);
|
|
13071
13791
|
return true;
|
|
13072
|
-
} catch {
|
|
13073
|
-
return
|
|
13792
|
+
} catch (err) {
|
|
13793
|
+
return !isDefinitelyGone(err);
|
|
13074
13794
|
}
|
|
13075
13795
|
}
|
|
13076
13796
|
/**
|
|
@@ -13567,7 +14287,7 @@ var StateValidator = class {
|
|
|
13567
14287
|
init_logger();
|
|
13568
14288
|
init_metrics();
|
|
13569
14289
|
init_config();
|
|
13570
|
-
import { dirname as dirname7, join as
|
|
14290
|
+
import { dirname as dirname7, join as join12 } from "node:path";
|
|
13571
14291
|
import { existsSync as existsSync7 } from "node:fs";
|
|
13572
14292
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
13573
14293
|
import { FixedClusterPool, WorkerChoiceStrategies } from "poolifier";
|
|
@@ -13578,7 +14298,7 @@ import { spawn } from "node:child_process";
|
|
|
13578
14298
|
import { createRequire as createRequire3 } from "node:module";
|
|
13579
14299
|
import { createHash as createHash2 } from "node:crypto";
|
|
13580
14300
|
import { existsSync as existsSync6, readdirSync as readdirSync2, statSync as statSync3, fstatSync, openSync as openSync3, closeSync as closeSync3, rmSync } from "node:fs";
|
|
13581
|
-
import { join as
|
|
14301
|
+
import { join as join10, dirname as dirname6 } from "node:path";
|
|
13582
14302
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
13583
14303
|
var FETCH_TIMEOUT_MS = 15 * 60 * 1e3;
|
|
13584
14304
|
var STALE_LOCK_MS = 20 * 60 * 1e3;
|
|
@@ -13591,7 +14311,7 @@ function isBrowserBinaryPresent() {
|
|
|
13591
14311
|
if (!existsSync6(cacheDir)) return false;
|
|
13592
14312
|
return readdirSync2(cacheDir).some((entry) => {
|
|
13593
14313
|
try {
|
|
13594
|
-
return statSync3(
|
|
14314
|
+
return statSync3(join10(cacheDir, entry)).isDirectory();
|
|
13595
14315
|
} catch {
|
|
13596
14316
|
return false;
|
|
13597
14317
|
}
|
|
@@ -13604,7 +14324,7 @@ function resolveCamoufoxBin() {
|
|
|
13604
14324
|
try {
|
|
13605
14325
|
const require2 = createRequire3(import.meta.url);
|
|
13606
14326
|
const pkgJson = require2.resolve("camoufox-js/package.json");
|
|
13607
|
-
const bin =
|
|
14327
|
+
const bin = join10(dirname6(pkgJson), "..", ".bin", process.platform === "win32" ? "camoufox-js.cmd" : "camoufox-js");
|
|
13608
14328
|
if (existsSync6(bin)) return bin;
|
|
13609
14329
|
} catch {
|
|
13610
14330
|
}
|
|
@@ -13648,7 +14368,7 @@ async function provision() {
|
|
|
13648
14368
|
return;
|
|
13649
14369
|
}
|
|
13650
14370
|
const cacheKey = createHash2("sha256").update(getCamoufoxBinaryPath()).digest("hex").slice(0, 16);
|
|
13651
|
-
const lockPath =
|
|
14371
|
+
const lockPath = join10(tmpdir2(), `pi-research-camoufox-fetch-${cacheKey}.lock`);
|
|
13652
14372
|
let haveLock = false;
|
|
13653
14373
|
try {
|
|
13654
14374
|
closeSync3(openSync3(lockPath, "wx", 384));
|
|
@@ -13703,12 +14423,26 @@ function ensureBrowserInstalled() {
|
|
|
13703
14423
|
|
|
13704
14424
|
// src/infrastructure/browser/cleanup-utils.ts
|
|
13705
14425
|
init_logger();
|
|
13706
|
-
import * as
|
|
14426
|
+
import * as fs10 from "node:fs/promises";
|
|
13707
14427
|
import * as path9 from "node:path";
|
|
13708
14428
|
import * as os4 from "node:os";
|
|
13709
14429
|
var DEFAULT_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
13710
14430
|
var LOCK_FILE_NAMES = ["lock", ".parentlock", "parent.lock"];
|
|
13711
14431
|
var LOCK_FRESHNESS_MS = 2 * 60 * 1e3;
|
|
14432
|
+
async function isWindowsParentLockHeld(lockPath) {
|
|
14433
|
+
let handle;
|
|
14434
|
+
try {
|
|
14435
|
+
handle = await fs10.open(lockPath, "r");
|
|
14436
|
+
} catch (e) {
|
|
14437
|
+
const code = e?.code;
|
|
14438
|
+
if (code === "ENOENT") return false;
|
|
14439
|
+
if (code === "EBUSY" || code === "EPERM" || code === "EACCES") return true;
|
|
14440
|
+
return null;
|
|
14441
|
+
}
|
|
14442
|
+
await handle.close().catch(() => {
|
|
14443
|
+
});
|
|
14444
|
+
return false;
|
|
14445
|
+
}
|
|
13712
14446
|
function parsePidFromLockTarget(target) {
|
|
13713
14447
|
const m = /\+(\d+)\s*$/.exec(target);
|
|
13714
14448
|
if (!m) return null;
|
|
@@ -13728,17 +14462,22 @@ async function isProfileLive(profileDir) {
|
|
|
13728
14462
|
const lockPath = path9.join(profileDir, name);
|
|
13729
14463
|
let lst;
|
|
13730
14464
|
try {
|
|
13731
|
-
lst = await
|
|
14465
|
+
lst = await fs10.lstat(lockPath);
|
|
13732
14466
|
} catch {
|
|
13733
14467
|
continue;
|
|
13734
14468
|
}
|
|
13735
14469
|
if (lst.isSymbolicLink()) {
|
|
13736
14470
|
try {
|
|
13737
|
-
const pid = parsePidFromLockTarget(await
|
|
14471
|
+
const pid = parsePidFromLockTarget(await fs10.readlink(lockPath));
|
|
13738
14472
|
if (pid !== null) return isPidAlive(pid);
|
|
13739
14473
|
} catch {
|
|
13740
14474
|
}
|
|
13741
14475
|
}
|
|
14476
|
+
if (process.platform === "win32" && name === "parent.lock") {
|
|
14477
|
+
const held = await isWindowsParentLockHeld(lockPath);
|
|
14478
|
+
if (held !== null) return held;
|
|
14479
|
+
return true;
|
|
14480
|
+
}
|
|
13742
14481
|
return Date.now() - lst.mtimeMs < LOCK_FRESHNESS_MS;
|
|
13743
14482
|
}
|
|
13744
14483
|
return false;
|
|
@@ -13750,19 +14489,19 @@ async function cleanupStaleProfiles(baseDir, staleMs = DEFAULT_STALE_MS) {
|
|
|
13750
14489
|
let removed = 0;
|
|
13751
14490
|
let errors = 0;
|
|
13752
14491
|
try {
|
|
13753
|
-
const entries = await
|
|
14492
|
+
const entries = await fs10.readdir(scanDir);
|
|
13754
14493
|
const now = Date.now();
|
|
13755
14494
|
for (const entry of entries) {
|
|
13756
14495
|
if (usePrefix && !entry.startsWith(prefix)) continue;
|
|
13757
14496
|
const fullPath = path9.join(scanDir, entry);
|
|
13758
14497
|
try {
|
|
13759
|
-
const stats = await
|
|
14498
|
+
const stats = await fs10.stat(fullPath);
|
|
13760
14499
|
if (!stats.isDirectory()) continue;
|
|
13761
14500
|
const isProfileLike = entry.startsWith(prefix) || entry.startsWith("playwright-artifacts-");
|
|
13762
14501
|
if (isProfileLike) {
|
|
13763
|
-
const inner = await
|
|
14502
|
+
const inner = await fs10.readdir(fullPath);
|
|
13764
14503
|
if (inner.length === 0) {
|
|
13765
|
-
await
|
|
14504
|
+
await fs10.rmdir(fullPath);
|
|
13766
14505
|
removed++;
|
|
13767
14506
|
continue;
|
|
13768
14507
|
}
|
|
@@ -13770,12 +14509,12 @@ async function cleanupStaleProfiles(baseDir, staleMs = DEFAULT_STALE_MS) {
|
|
|
13770
14509
|
logger.debug(`[Cleanup] Skipping profile ${entry} \u2014 owned by a live browser`);
|
|
13771
14510
|
continue;
|
|
13772
14511
|
}
|
|
13773
|
-
await
|
|
14512
|
+
await fs10.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
13774
14513
|
removed++;
|
|
13775
14514
|
continue;
|
|
13776
14515
|
}
|
|
13777
14516
|
if (now - stats.mtimeMs > staleMs && !await isProfileLive(fullPath)) {
|
|
13778
|
-
await
|
|
14517
|
+
await fs10.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
13779
14518
|
removed++;
|
|
13780
14519
|
}
|
|
13781
14520
|
} catch (_e) {
|
|
@@ -13795,7 +14534,7 @@ async function cleanupStaleProfiles(baseDir, staleMs = DEFAULT_STALE_MS) {
|
|
|
13795
14534
|
init_log_utils();
|
|
13796
14535
|
import process3 from "node:process";
|
|
13797
14536
|
import cluster from "node:cluster";
|
|
13798
|
-
import * as
|
|
14537
|
+
import * as fs11 from "node:fs/promises";
|
|
13799
14538
|
var workerId = "";
|
|
13800
14539
|
var BENIGN_IPC_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_IPC_CHANNEL_CLOSED", "ECONNRESET"]);
|
|
13801
14540
|
function isBenignClusterIpcError(err) {
|
|
@@ -13856,7 +14595,7 @@ function logToDebugFile(level, ...args) {
|
|
|
13856
14595
|
return String(arg);
|
|
13857
14596
|
}).join(" "))
|
|
13858
14597
|
};
|
|
13859
|
-
|
|
14598
|
+
fs11.appendFile(logFile, `${JSON.stringify(entry)}
|
|
13860
14599
|
`).catch(() => {
|
|
13861
14600
|
});
|
|
13862
14601
|
} catch {
|
|
@@ -13931,6 +14670,7 @@ var WorkerPoolManager = class {
|
|
|
13931
14670
|
try {
|
|
13932
14671
|
if (this.pool && this.currentWorkerCount !== maxWorkers) {
|
|
13933
14672
|
logger.log(`[WorkerPoolManager] Worker count changed from ${this.currentWorkerCount} to ${maxWorkers}, recreating pool...`);
|
|
14673
|
+
this.poolEpoch++;
|
|
13934
14674
|
await this.pool.destroy();
|
|
13935
14675
|
this.pool = null;
|
|
13936
14676
|
}
|
|
@@ -13945,7 +14685,7 @@ var WorkerPoolManager = class {
|
|
|
13945
14685
|
void cleanupStaleProfiles(void 0, STALE_PROFILE_MS).catch((e) => logger.debug("[WorkerPoolManager] Profile sweep (legacy tmp) failed:", e));
|
|
13946
14686
|
void cleanupOrphanedCamoufoxProcesses().catch((e) => logger.debug("[WorkerPoolManager] Startup orphan sweep failed:", e));
|
|
13947
14687
|
const workerConcurrency = (config || getConfig()).WORKER_CONCURRENCY;
|
|
13948
|
-
const workerPath =
|
|
14688
|
+
const workerPath = join12(__dirname, "./thread-worker.mjs");
|
|
13949
14689
|
if (!existsSync7(workerPath)) {
|
|
13950
14690
|
throw new Error(
|
|
13951
14691
|
`Browser worker bundle is missing at ${workerPath}. It is produced by the build step; reinstall the extension (e.g. \`pi update --extensions\`) or run \`npm install\` in the extension directory so the prepare/build script regenerates it.`
|
|
@@ -13975,7 +14715,7 @@ var WorkerPoolManager = class {
|
|
|
13975
14715
|
}
|
|
13976
14716
|
},
|
|
13977
14717
|
exitHandler: (code) => {
|
|
13978
|
-
if (code !== 0 &&
|
|
14718
|
+
if (code !== 0 && this.poolEpoch === myEpoch) {
|
|
13979
14719
|
logger.error(`[WorkerPoolManager] Worker exited with code ${code}`);
|
|
13980
14720
|
this.consecutiveErrors++;
|
|
13981
14721
|
if (this.consecutiveErrors >= 3) {
|
|
@@ -13998,6 +14738,7 @@ var WorkerPoolManager = class {
|
|
|
13998
14738
|
logger.warn("[WorkerPoolManager] Pool initialized but a shutdown intervened. Destroying...");
|
|
13999
14739
|
const built = this.pool;
|
|
14000
14740
|
if (this.pool === built) this.pool = null;
|
|
14741
|
+
this.poolEpoch++;
|
|
14001
14742
|
await built.destroy().catch((err) => logger.debug("Swallowed pool destroy error:", err));
|
|
14002
14743
|
throw new Error("Worker pool is shutting down");
|
|
14003
14744
|
}
|
|
@@ -14058,6 +14799,7 @@ var WorkerPoolManager = class {
|
|
|
14058
14799
|
const t = setTimeout(async () => {
|
|
14059
14800
|
try {
|
|
14060
14801
|
if (this.isShuttingDown || this.generation !== myGen) return;
|
|
14802
|
+
this.poolEpoch++;
|
|
14061
14803
|
if (deadPool) await deadPool.destroy();
|
|
14062
14804
|
logger.info("[WorkerPoolManager] Auto-recovery: old pool destroyed.");
|
|
14063
14805
|
} catch (err) {
|
|
@@ -14098,6 +14840,7 @@ var WorkerPoolManager = class {
|
|
|
14098
14840
|
}
|
|
14099
14841
|
if (this.pool) {
|
|
14100
14842
|
try {
|
|
14843
|
+
this.poolEpoch++;
|
|
14101
14844
|
const destroyPromise = this.pool.destroy();
|
|
14102
14845
|
destroyPromise.catch((err) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
|
|
14103
14846
|
await raceWithDeadline(destroyPromise, 5e3);
|
|
@@ -14275,7 +15018,7 @@ var ResearchRunSemaphore = class {
|
|
|
14275
15018
|
if (this._initialized) return;
|
|
14276
15019
|
this.lifecycle = "initializing" /* INITIALIZING */;
|
|
14277
15020
|
try {
|
|
14278
|
-
await import("node:fs/promises").then((
|
|
15021
|
+
await import("node:fs/promises").then((fs18) => fs18.mkdir(this.slotDir, { recursive: true, mode: 448 }));
|
|
14279
15022
|
} catch (err) {
|
|
14280
15023
|
logger.warn(`[ResearchRunSemaphore] Could not create slot dir ${this.slotDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
14281
15024
|
}
|
|
@@ -14410,9 +15153,10 @@ function sleep(ms, signal) {
|
|
|
14410
15153
|
|
|
14411
15154
|
// src/infrastructure/state/state-backup-manager.ts
|
|
14412
15155
|
init_logger();
|
|
15156
|
+
init_atomic_replace();
|
|
14413
15157
|
init_service_registry();
|
|
14414
15158
|
init_service_names();
|
|
14415
|
-
import * as
|
|
15159
|
+
import * as fs12 from "node:fs/promises";
|
|
14416
15160
|
import * as crypto6 from "node:crypto";
|
|
14417
15161
|
import * as path11 from "node:path";
|
|
14418
15162
|
var QUARANTINE_KEEP = 5;
|
|
@@ -14436,7 +15180,7 @@ var StateBackupManager = class {
|
|
|
14436
15180
|
return;
|
|
14437
15181
|
}
|
|
14438
15182
|
this.lifecycle = "initializing" /* INITIALIZING */;
|
|
14439
|
-
await
|
|
15183
|
+
await fs12.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
|
|
14440
15184
|
this._initialized = true;
|
|
14441
15185
|
this.lifecycle = "initialized" /* INITIALIZED */;
|
|
14442
15186
|
}
|
|
@@ -14450,15 +15194,15 @@ var StateBackupManager = class {
|
|
|
14450
15194
|
async createBackup() {
|
|
14451
15195
|
try {
|
|
14452
15196
|
try {
|
|
14453
|
-
await
|
|
15197
|
+
await fs12.access(this.stateFilePath);
|
|
14454
15198
|
} catch {
|
|
14455
15199
|
return;
|
|
14456
15200
|
}
|
|
14457
|
-
await
|
|
15201
|
+
await fs12.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
|
|
14458
15202
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
14459
15203
|
const backupFileName = `research-state-${timestamp}-${crypto6.randomBytes(4).toString("hex")}.json`;
|
|
14460
15204
|
const backupFilePath = path11.join(this.backupDirPath, backupFileName);
|
|
14461
|
-
await
|
|
15205
|
+
await fs12.copyFile(this.stateFilePath, backupFilePath);
|
|
14462
15206
|
} catch (error) {
|
|
14463
15207
|
throw new Error(`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
14464
15208
|
}
|
|
@@ -14468,14 +15212,14 @@ var StateBackupManager = class {
|
|
|
14468
15212
|
*/
|
|
14469
15213
|
async cleanupOldBackups() {
|
|
14470
15214
|
try {
|
|
14471
|
-
const entries = await
|
|
15215
|
+
const entries = await fs12.readdir(this.backupDirPath);
|
|
14472
15216
|
if (entries.length <= this.maxBackups) {
|
|
14473
15217
|
return;
|
|
14474
15218
|
}
|
|
14475
15219
|
const backupFiles = [];
|
|
14476
15220
|
for (const entry of entries) {
|
|
14477
15221
|
const filePath = path11.join(this.backupDirPath, entry);
|
|
14478
|
-
const stats = await
|
|
15222
|
+
const stats = await fs12.stat(filePath);
|
|
14479
15223
|
if (stats.isFile() && entry.startsWith("research-state-") && entry.endsWith(".json")) {
|
|
14480
15224
|
backupFiles.push({ name: entry, mtime: stats.mtime });
|
|
14481
15225
|
}
|
|
@@ -14484,7 +15228,7 @@ var StateBackupManager = class {
|
|
|
14484
15228
|
const backupsToRemove = backupFiles.slice(this.maxBackups);
|
|
14485
15229
|
for (const backupFile of backupsToRemove) {
|
|
14486
15230
|
const filePath = path11.join(this.backupDirPath, backupFile.name);
|
|
14487
|
-
await
|
|
15231
|
+
await fs12.unlink(filePath);
|
|
14488
15232
|
}
|
|
14489
15233
|
} catch (error) {
|
|
14490
15234
|
logger.error(`Failed to cleanup old backups: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -14497,10 +15241,10 @@ var StateBackupManager = class {
|
|
|
14497
15241
|
try {
|
|
14498
15242
|
let backups = [];
|
|
14499
15243
|
try {
|
|
14500
|
-
const entries = await
|
|
15244
|
+
const entries = await fs12.readdir(this.backupDirPath);
|
|
14501
15245
|
for (const entry of entries) {
|
|
14502
15246
|
const filePath = path11.join(this.backupDirPath, entry);
|
|
14503
|
-
const stats = await
|
|
15247
|
+
const stats = await fs12.stat(filePath);
|
|
14504
15248
|
if (stats.isFile() && entry.startsWith("research-state-") && entry.endsWith(".json")) {
|
|
14505
15249
|
backups.push({ name: entry, mtime: stats.mtime });
|
|
14506
15250
|
}
|
|
@@ -14512,7 +15256,7 @@ var StateBackupManager = class {
|
|
|
14512
15256
|
const backupPath = path11.join(this.backupDirPath, backup.name);
|
|
14513
15257
|
let restoreContent;
|
|
14514
15258
|
try {
|
|
14515
|
-
const content = await
|
|
15259
|
+
const content = await fs12.readFile(backupPath, "utf-8");
|
|
14516
15260
|
const validated = this.validator.migrateAndValidate(JSON.parse(content));
|
|
14517
15261
|
restoreContent = JSON.stringify(validated, null, 2);
|
|
14518
15262
|
} catch (err) {
|
|
@@ -14540,27 +15284,27 @@ var StateBackupManager = class {
|
|
|
14540
15284
|
*/
|
|
14541
15285
|
async quarantineFutureState(fileVersion) {
|
|
14542
15286
|
try {
|
|
14543
|
-
await
|
|
15287
|
+
await fs12.access(this.stateFilePath);
|
|
14544
15288
|
} catch {
|
|
14545
15289
|
return;
|
|
14546
15290
|
}
|
|
14547
|
-
await
|
|
15291
|
+
await fs12.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
|
|
14548
15292
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
14549
15293
|
const name = `research-state-future-v${fileVersion}-${timestamp}.quarantine`;
|
|
14550
15294
|
const dest = path11.join(this.backupDirPath, name);
|
|
14551
|
-
await
|
|
15295
|
+
await fs12.copyFile(this.stateFilePath, dest);
|
|
14552
15296
|
logger.warn(`[StateManager] Quarantined newer-build state (v${fileVersion}) copy at ${dest}`);
|
|
14553
15297
|
await this.pruneQuarantineFiles(QUARANTINE_KEEP);
|
|
14554
15298
|
}
|
|
14555
15299
|
/** Keep only the newest `keep` *.quarantine files; delete the rest. Best-effort. */
|
|
14556
15300
|
async pruneQuarantineFiles(keep) {
|
|
14557
15301
|
try {
|
|
14558
|
-
const entries = await
|
|
15302
|
+
const entries = await fs12.readdir(this.backupDirPath);
|
|
14559
15303
|
const quarantines = [];
|
|
14560
15304
|
for (const entry of entries) {
|
|
14561
15305
|
if (!entry.endsWith(".quarantine")) continue;
|
|
14562
15306
|
try {
|
|
14563
|
-
const stats = await
|
|
15307
|
+
const stats = await fs12.stat(path11.join(this.backupDirPath, entry));
|
|
14564
15308
|
if (stats.isFile()) quarantines.push({ name: entry, mtime: stats.mtimeMs });
|
|
14565
15309
|
} catch {
|
|
14566
15310
|
}
|
|
@@ -14569,7 +15313,7 @@ var StateBackupManager = class {
|
|
|
14569
15313
|
quarantines.sort((a, b) => b.mtime - a.mtime);
|
|
14570
15314
|
for (const stale of quarantines.slice(keep)) {
|
|
14571
15315
|
try {
|
|
14572
|
-
await
|
|
15316
|
+
await fs12.unlink(path11.join(this.backupDirPath, stale.name));
|
|
14573
15317
|
} catch {
|
|
14574
15318
|
}
|
|
14575
15319
|
}
|
|
@@ -14582,10 +15326,10 @@ var StateBackupManager = class {
|
|
|
14582
15326
|
*/
|
|
14583
15327
|
async atomicWriteStateFile(content) {
|
|
14584
15328
|
const stateDir = path11.dirname(this.stateFilePath);
|
|
14585
|
-
await
|
|
15329
|
+
await fs12.mkdir(stateDir, { recursive: true, mode: 448 });
|
|
14586
15330
|
const tempFile = `research-state-${crypto6.randomBytes(16).toString("hex")}.tmp`;
|
|
14587
15331
|
const tempPath = path11.join(stateDir, tempFile);
|
|
14588
|
-
const fh = await
|
|
15332
|
+
const fh = await fs12.open(tempPath, "w", 384);
|
|
14589
15333
|
try {
|
|
14590
15334
|
await fh.writeFile(content, { encoding: "utf-8" });
|
|
14591
15335
|
await fh.sync();
|
|
@@ -14593,17 +15337,14 @@ var StateBackupManager = class {
|
|
|
14593
15337
|
await fh.close();
|
|
14594
15338
|
}
|
|
14595
15339
|
try {
|
|
14596
|
-
await
|
|
14597
|
-
|
|
14598
|
-
if (process.platform === "win32") {
|
|
14599
|
-
await fs11.copyFile(tempPath, this.stateFilePath);
|
|
14600
|
-
await fs11.unlink(tempPath).catch(() => {
|
|
14601
|
-
});
|
|
14602
|
-
} else {
|
|
14603
|
-
await fs11.unlink(tempPath).catch(() => {
|
|
15340
|
+
if (await replaceFile(tempPath, this.stateFilePath) === "copied") {
|
|
15341
|
+
await fs12.unlink(tempPath).catch(() => {
|
|
14604
15342
|
});
|
|
14605
|
-
throw renameErr;
|
|
14606
15343
|
}
|
|
15344
|
+
} catch (renameErr) {
|
|
15345
|
+
await fs12.unlink(tempPath).catch(() => {
|
|
15346
|
+
});
|
|
15347
|
+
throw renameErr;
|
|
14607
15348
|
}
|
|
14608
15349
|
}
|
|
14609
15350
|
/**
|
|
@@ -14910,6 +15651,41 @@ async function recoverFromLeaderHandover(container) {
|
|
|
14910
15651
|
(err) => logger.debug("Wait for browser idle timed out or failed:", err)
|
|
14911
15652
|
);
|
|
14912
15653
|
}
|
|
15654
|
+
async function isRegisteredLeaderAliveUncached(container) {
|
|
15655
|
+
try {
|
|
15656
|
+
const stateManager = await getService(ServiceNames.STATE_MANAGER, void 0, container);
|
|
15657
|
+
const info = await stateManager.getBrowserServer();
|
|
15658
|
+
if (!info) return false;
|
|
15659
|
+
const schedulerService = await getService(ServiceNames.SCHEDULER, void 0, container);
|
|
15660
|
+
const cached = schedulerService.getSchedulerInstance();
|
|
15661
|
+
if (cached instanceof BrowserClient && cached.targetPort !== info.port) return false;
|
|
15662
|
+
return await stateManager.isPidAlive(info.pid, info.schedulerId);
|
|
15663
|
+
} catch {
|
|
15664
|
+
return false;
|
|
15665
|
+
}
|
|
15666
|
+
}
|
|
15667
|
+
var LEADER_LIVENESS_COALESCE_MS = 1e3;
|
|
15668
|
+
var leaderLivenessProbe = null;
|
|
15669
|
+
async function isRegisteredLeaderAlive(container) {
|
|
15670
|
+
const now = Date.now();
|
|
15671
|
+
if (leaderLivenessProbe && now - leaderLivenessProbe.startedAt < LEADER_LIVENESS_COALESCE_MS) {
|
|
15672
|
+
return leaderLivenessProbe.result;
|
|
15673
|
+
}
|
|
15674
|
+
const result = isRegisteredLeaderAliveUncached(container);
|
|
15675
|
+
leaderLivenessProbe = { startedAt: now, result };
|
|
15676
|
+
return result;
|
|
15677
|
+
}
|
|
15678
|
+
async function restartSchedulerWithHerdGuard(container) {
|
|
15679
|
+
const now = Date.now();
|
|
15680
|
+
if (now - lastRestartTime > RESTART_COOLDOWN_MS || !await isRegisteredLeaderAlive(container)) {
|
|
15681
|
+
lastRestartTime = now;
|
|
15682
|
+
logger.warn("[BrowserManager] Forcing scheduler restart and retrying...");
|
|
15683
|
+
await forceSchedulerRestart2(false, container);
|
|
15684
|
+
} else {
|
|
15685
|
+
logger.warn("[BrowserManager] Scheduler restart triggered recently and the registered leader is still alive \u2014 waiting for pool idle instead...");
|
|
15686
|
+
}
|
|
15687
|
+
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
15688
|
+
}
|
|
14913
15689
|
async function getScheduler2(config, container = getServiceContainer()) {
|
|
14914
15690
|
try {
|
|
14915
15691
|
const factory = await getService(ServiceNames.SCHEDULER_FACTORY, void 0, container);
|
|
@@ -14972,16 +15748,7 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
|
|
|
14972
15748
|
logger.warn(`[BrowserManager] Pool is draining \u2014 waiting for pool idle before retry...`);
|
|
14973
15749
|
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
14974
15750
|
} else {
|
|
14975
|
-
|
|
14976
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
14977
|
-
lastRestartTime = now;
|
|
14978
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
14979
|
-
await forceSchedulerRestart2(false, container);
|
|
14980
|
-
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
14981
|
-
} else {
|
|
14982
|
-
logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
|
|
14983
|
-
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
14984
|
-
}
|
|
15751
|
+
await restartSchedulerWithHerdGuard(container);
|
|
14985
15752
|
}
|
|
14986
15753
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
14987
15754
|
await new Promise((resolve8) => setTimeout(resolve8, jitter));
|
|
@@ -15007,15 +15774,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
|
|
|
15007
15774
|
if (isPoolShutdownError(error)) {
|
|
15008
15775
|
await recoverFromLeaderHandover(container);
|
|
15009
15776
|
} else {
|
|
15010
|
-
|
|
15011
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
15012
|
-
lastRestartTime = now;
|
|
15013
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
15014
|
-
await forceSchedulerRestart2(false, container);
|
|
15015
|
-
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
15016
|
-
} else {
|
|
15017
|
-
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
15018
|
-
}
|
|
15777
|
+
await restartSchedulerWithHerdGuard(container);
|
|
15019
15778
|
}
|
|
15020
15779
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
15021
15780
|
await new Promise((resolve8) => setTimeout(resolve8, jitter));
|
|
@@ -15059,15 +15818,7 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
|
|
|
15059
15818
|
if (isPoolShutdownError(error)) {
|
|
15060
15819
|
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
15061
15820
|
} else {
|
|
15062
|
-
|
|
15063
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
15064
|
-
lastRestartTime = now;
|
|
15065
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
15066
|
-
await forceSchedulerRestart2(false, container);
|
|
15067
|
-
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
15068
|
-
} else {
|
|
15069
|
-
await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
|
|
15070
|
-
}
|
|
15821
|
+
await restartSchedulerWithHerdGuard(container);
|
|
15071
15822
|
}
|
|
15072
15823
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
15073
15824
|
await new Promise((resolve8) => setTimeout(resolve8, jitter));
|
|
@@ -15083,7 +15834,7 @@ init_safe_unref();
|
|
|
15083
15834
|
init_url_utils();
|
|
15084
15835
|
init_metrics();
|
|
15085
15836
|
init_service_registry();
|
|
15086
|
-
async function performSearch(queries, config, signal, onProgress, container = getServiceContainer()) {
|
|
15837
|
+
async function performSearch(queries, config, signal, onProgress, container = getServiceContainer(), failures) {
|
|
15087
15838
|
const startTime = Date.now();
|
|
15088
15839
|
const resultMap = /* @__PURE__ */ new Map();
|
|
15089
15840
|
const seenUrls = /* @__PURE__ */ new Set();
|
|
@@ -15106,7 +15857,7 @@ async function performSearch(queries, config, signal, onProgress, container = ge
|
|
|
15106
15857
|
logger.log(`[Search] Orchestrating ${queries.length} queries across ${maxWorkers} worker processes...`);
|
|
15107
15858
|
if (onProgress) onProgress(0);
|
|
15108
15859
|
const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45e3) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 1e4);
|
|
15109
|
-
const filteredQueries = queries.filter((q) => q.trim());
|
|
15860
|
+
const filteredQueries = [...new Set(queries.filter((q) => q.trim()))];
|
|
15110
15861
|
let timeoutCount = 0;
|
|
15111
15862
|
let errorCount = 0;
|
|
15112
15863
|
let sampleWorkerError = "";
|
|
@@ -15151,11 +15902,24 @@ async function performSearch(queries, config, signal, onProgress, container = ge
|
|
|
15151
15902
|
metrics.increment("browser_search_queries_total", 1, { status });
|
|
15152
15903
|
if (isTimeout) {
|
|
15153
15904
|
logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms \u2014 likely starved waiting for a free browser worker under concurrent load, or a slow/blocked search provider: "${query}"`);
|
|
15905
|
+
failures?.set(query, {
|
|
15906
|
+
type: "timeout",
|
|
15907
|
+
message: `Search timed out after ${QUERY_TIMEOUT_MS}ms before returning anything. This says nothing about the query \u2014 the browser pool was saturated or the search provider was slow/blocked. Retrying the same query later is reasonable.`
|
|
15908
|
+
});
|
|
15154
15909
|
} else {
|
|
15155
15910
|
const msg2 = error instanceof Error ? error.message : String(error);
|
|
15156
15911
|
if (msg2 !== "Aborted") {
|
|
15157
15912
|
if (!sampleWorkerError) sampleWorkerError = msg2;
|
|
15158
15913
|
logger.error(`[Search] Worker failed for "${query}": ${msg2}`);
|
|
15914
|
+
failures?.set(query, {
|
|
15915
|
+
type: "service_unavailable",
|
|
15916
|
+
message: `The search backend failed for this query (${msg2}). This is an infrastructure failure, not a signal about the query itself \u2014 do not rewrite the query in response to it.`
|
|
15917
|
+
});
|
|
15918
|
+
} else if (!signal?.aborted) {
|
|
15919
|
+
failures?.set(query, {
|
|
15920
|
+
type: "service_unavailable",
|
|
15921
|
+
message: "The search task was cancelled on the worker side (pool drain or leader handover) before completing. This says nothing about the query \u2014 do not rewrite it; retrying the same query later is reasonable."
|
|
15922
|
+
});
|
|
15159
15923
|
}
|
|
15160
15924
|
}
|
|
15161
15925
|
resultMap.set(query, []);
|
|
@@ -15165,6 +15929,9 @@ async function performSearch(queries, config, signal, onProgress, container = ge
|
|
|
15165
15929
|
}
|
|
15166
15930
|
});
|
|
15167
15931
|
await Promise.all(searchTasks);
|
|
15932
|
+
if (signal?.aborted) {
|
|
15933
|
+
throw new Error("Aborted");
|
|
15934
|
+
}
|
|
15168
15935
|
const totalResults = Array.from(resultMap.values()).reduce((sum, r) => sum + r.length, 0);
|
|
15169
15936
|
if (totalResults === 0 && filteredQueries.length > 0) {
|
|
15170
15937
|
metrics.increment("browser_search_total_failures_total", 1);
|
|
@@ -15195,7 +15962,8 @@ async function search(queries, config, signal, onProgress, container = getServic
|
|
|
15195
15962
|
metrics.observe("search_query_count_per_request", queries.length);
|
|
15196
15963
|
const searchStart = Date.now();
|
|
15197
15964
|
try {
|
|
15198
|
-
const
|
|
15965
|
+
const failures = /* @__PURE__ */ new Map();
|
|
15966
|
+
const resultMap = await performSearch(queries, config, signal, onProgress, container, failures);
|
|
15199
15967
|
const searchDuration = Date.now() - searchStart;
|
|
15200
15968
|
metrics.observe("search_latency_ms", searchDuration);
|
|
15201
15969
|
let totalResults = 0;
|
|
@@ -15209,7 +15977,7 @@ async function search(queries, config, signal, onProgress, container = getServic
|
|
|
15209
15977
|
successfulQueries++;
|
|
15210
15978
|
metrics.observe("search_results_per_query", qResults.length);
|
|
15211
15979
|
} else {
|
|
15212
|
-
result.error = {
|
|
15980
|
+
result.error = failures.get(q) ?? {
|
|
15213
15981
|
type: "empty_results",
|
|
15214
15982
|
message: "Browser-based search returned no relevant results for this query (the query may be too narrow, or the site returned nothing usable)."
|
|
15215
15983
|
};
|
|
@@ -15224,10 +15992,13 @@ async function search(queries, config, signal, onProgress, container = getServic
|
|
|
15224
15992
|
return results;
|
|
15225
15993
|
} catch (error) {
|
|
15226
15994
|
const searchDuration = Date.now() - searchStart;
|
|
15995
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15996
|
+
if (signal?.aborted || message === "Aborted") {
|
|
15997
|
+
throw error;
|
|
15998
|
+
}
|
|
15227
15999
|
metrics.observe("search_latency_ms", searchDuration, { status: "error" });
|
|
15228
16000
|
metrics.increment("search_queries_total", queries.length, { status: "error" });
|
|
15229
16001
|
metrics.increment("search_errors_total", 1, { error_type: "search_failed" });
|
|
15230
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
15231
16002
|
if (message.includes("Search completely failed")) {
|
|
15232
16003
|
metrics.increment("search_errors_total", 1, { error_type: "total_search_failure" });
|
|
15233
16004
|
throw error;
|
|
@@ -15602,7 +16373,9 @@ function createSearchTool(options) {
|
|
|
15602
16373
|
markdown += `## Query ${i + 1}: ${r.query}
|
|
15603
16374
|
`;
|
|
15604
16375
|
if (r.results.length === 0) {
|
|
15605
|
-
markdown +=
|
|
16376
|
+
markdown += r.error ? `*${r.error.message}*
|
|
16377
|
+
|
|
16378
|
+
` : `*No results found.*
|
|
15606
16379
|
|
|
15607
16380
|
`;
|
|
15608
16381
|
} else {
|
|
@@ -15746,7 +16519,7 @@ init_logger();
|
|
|
15746
16519
|
import * as dns from "node:dns/promises";
|
|
15747
16520
|
import * as dnsCb from "node:dns";
|
|
15748
16521
|
import * as net3 from "node:net";
|
|
15749
|
-
import
|
|
16522
|
+
import crypto9 from "node:crypto";
|
|
15750
16523
|
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
15751
16524
|
function isBenignScrapeFailure(error) {
|
|
15752
16525
|
const msg2 = error instanceof Error ? error.message : String(error);
|
|
@@ -15757,7 +16530,7 @@ function isBenignScrapeFailure(error) {
|
|
|
15757
16530
|
}
|
|
15758
16531
|
function getRandomUserAgent() {
|
|
15759
16532
|
try {
|
|
15760
|
-
const index =
|
|
16533
|
+
const index = crypto9.randomInt(0, USER_AGENTS.length);
|
|
15761
16534
|
const userAgent = USER_AGENTS[index];
|
|
15762
16535
|
return userAgent ?? USER_AGENTS[0];
|
|
15763
16536
|
} catch {
|
|
@@ -15999,6 +16772,7 @@ function validateContent(html, markdown, url) {
|
|
|
15999
16772
|
// src/web-research/web-scraper.ts
|
|
16000
16773
|
init_retry_utils();
|
|
16001
16774
|
init_safe_unref();
|
|
16775
|
+
init_http_body();
|
|
16002
16776
|
init_service_registry();
|
|
16003
16777
|
var FETCH_RETRY_DELAY_MS = 250;
|
|
16004
16778
|
var playwrightAvailable = false;
|
|
@@ -16095,48 +16869,6 @@ function decodeHtmlBody(bytes, contentTypeHeader) {
|
|
|
16095
16869
|
return new TextDecoder("utf-8").decode(bytes);
|
|
16096
16870
|
}
|
|
16097
16871
|
}
|
|
16098
|
-
var BodyTooLargeError = class extends Error {
|
|
16099
|
-
constructor(bytesRead, maxBytes) {
|
|
16100
|
-
super(`Response body exceeded ${maxBytes} byte cap`);
|
|
16101
|
-
this.bytesRead = bytesRead;
|
|
16102
|
-
this.maxBytes = maxBytes;
|
|
16103
|
-
this.name = "BodyTooLargeError";
|
|
16104
|
-
}
|
|
16105
|
-
bytesRead;
|
|
16106
|
-
maxBytes;
|
|
16107
|
-
};
|
|
16108
|
-
async function readBodyCapped(response, maxBytes) {
|
|
16109
|
-
const body = response.body;
|
|
16110
|
-
if (!body || typeof body.getReader !== "function") {
|
|
16111
|
-
return new Uint8Array(await response.arrayBuffer());
|
|
16112
|
-
}
|
|
16113
|
-
const reader = body.getReader();
|
|
16114
|
-
const chunks = [];
|
|
16115
|
-
let total = 0;
|
|
16116
|
-
try {
|
|
16117
|
-
for (; ; ) {
|
|
16118
|
-
const { done, value } = await reader.read();
|
|
16119
|
-
if (done) break;
|
|
16120
|
-
if (value) {
|
|
16121
|
-
total += value.byteLength;
|
|
16122
|
-
if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
|
|
16123
|
-
chunks.push(value);
|
|
16124
|
-
}
|
|
16125
|
-
}
|
|
16126
|
-
} finally {
|
|
16127
|
-
try {
|
|
16128
|
-
await reader.cancel();
|
|
16129
|
-
} catch {
|
|
16130
|
-
}
|
|
16131
|
-
}
|
|
16132
|
-
const out = new Uint8Array(total);
|
|
16133
|
-
let offset = 0;
|
|
16134
|
-
for (const c of chunks) {
|
|
16135
|
-
out.set(c, offset);
|
|
16136
|
-
offset += c.byteLength;
|
|
16137
|
-
}
|
|
16138
|
-
return out;
|
|
16139
|
-
}
|
|
16140
16872
|
function trackFetchLayerFailure(url, error) {
|
|
16141
16873
|
const isSsrf = error instanceof Error && error.message.includes("not allowed");
|
|
16142
16874
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
@@ -16710,6 +17442,7 @@ init_retry_utils();
|
|
|
16710
17442
|
init_circuit_breaker();
|
|
16711
17443
|
init_safe_unref();
|
|
16712
17444
|
init_metrics();
|
|
17445
|
+
init_http_body();
|
|
16713
17446
|
|
|
16714
17447
|
// src/security/nvd-types.ts
|
|
16715
17448
|
function isWeaknessDescription(value) {
|
|
@@ -16998,18 +17731,20 @@ async function fetchPaginated(term, options, maxResults, severityVersion, admitE
|
|
|
16998
17731
|
const response = await fetchWithRetry(url, signal);
|
|
16999
17732
|
let data;
|
|
17000
17733
|
try {
|
|
17001
|
-
data = await response
|
|
17002
|
-
} catch {
|
|
17003
|
-
metrics.increment("nvd_search_errors_total", 1, {
|
|
17734
|
+
data = await readJsonCapped(response, MAX_BULK_JSON_BODY_BYTES);
|
|
17735
|
+
} catch (err) {
|
|
17736
|
+
metrics.increment("nvd_search_errors_total", 1, {
|
|
17737
|
+
error_type: err instanceof BodyTooLargeError ? "body_too_large" : "malformed_json"
|
|
17738
|
+
});
|
|
17004
17739
|
break;
|
|
17005
17740
|
}
|
|
17006
17741
|
const rawEntries = extractNVDEntries(data);
|
|
17007
17742
|
const admitted = rawEntries.filter(admitEntry).map((entry) => parseNVDEntry(entry, options));
|
|
17008
17743
|
if (rawEntries.length === 0) {
|
|
17009
|
-
metrics.increment("nvd_cache_misses_total", 1
|
|
17744
|
+
metrics.increment("nvd_cache_misses_total", 1);
|
|
17010
17745
|
break;
|
|
17011
17746
|
}
|
|
17012
|
-
metrics.increment("nvd_cache_hits_total", 1
|
|
17747
|
+
metrics.increment("nvd_cache_hits_total", 1);
|
|
17013
17748
|
allVulnerabilities.push(...admitted);
|
|
17014
17749
|
const responseData = data;
|
|
17015
17750
|
if (responseData?.["totalResults"] !== void 0 && typeof responseData["totalResults"] === "number" && startIndex + pageSize >= responseData["totalResults"]) {
|
|
@@ -17103,6 +17838,7 @@ async function searchNVD(terms, options) {
|
|
|
17103
17838
|
init_retry_utils();
|
|
17104
17839
|
init_circuit_breaker();
|
|
17105
17840
|
init_metrics();
|
|
17841
|
+
init_http_body();
|
|
17106
17842
|
var CISA_KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
|
|
17107
17843
|
var cisaCircuitBreaker = new CircuitBreaker({
|
|
17108
17844
|
failureThreshold: 5,
|
|
@@ -17186,10 +17922,14 @@ async function searchCisaKev(terms = [], options) {
|
|
|
17186
17922
|
}, options?.signal);
|
|
17187
17923
|
let data;
|
|
17188
17924
|
try {
|
|
17189
|
-
data = await response
|
|
17190
|
-
} catch {
|
|
17925
|
+
data = await readJsonCapped(response, MAX_BULK_JSON_BODY_BYTES);
|
|
17926
|
+
} catch (err) {
|
|
17927
|
+
if (err instanceof BodyTooLargeError) {
|
|
17928
|
+
metrics.increment("cisa_kev_errors_total", 1, { error_type: "body_too_large" });
|
|
17929
|
+
throw err;
|
|
17930
|
+
}
|
|
17191
17931
|
metrics.increment("cisa_kev_errors_total", 1, { error_type: "malformed_json" });
|
|
17192
|
-
throw new Error("CISA KEV feed returned a malformed JSON response.");
|
|
17932
|
+
throw new Error("CISA KEV feed returned a malformed JSON response.", { cause: err });
|
|
17193
17933
|
}
|
|
17194
17934
|
const cisaData = extractCisaKevItems(data);
|
|
17195
17935
|
metrics.increment(cisaData.length > 0 ? "cisa_kev_cache_hits_total" : "cisa_kev_cache_misses_total", 1);
|
|
@@ -17312,6 +18052,7 @@ function normalizeEcosystem(value, target) {
|
|
|
17312
18052
|
init_retry_utils();
|
|
17313
18053
|
init_circuit_breaker();
|
|
17314
18054
|
init_metrics();
|
|
18055
|
+
init_http_body();
|
|
17315
18056
|
init_logger();
|
|
17316
18057
|
|
|
17317
18058
|
// src/security/github-advisory-types.ts
|
|
@@ -17533,10 +18274,14 @@ async function searchGitHubAdvisories(terms, options) {
|
|
|
17533
18274
|
}));
|
|
17534
18275
|
let data;
|
|
17535
18276
|
try {
|
|
17536
|
-
data = await response
|
|
17537
|
-
} catch {
|
|
18277
|
+
data = await readJsonCapped(response);
|
|
18278
|
+
} catch (err) {
|
|
18279
|
+
if (err instanceof BodyTooLargeError) {
|
|
18280
|
+
metrics.increment("github_search_errors_total", 1, { error_type: "body_too_large" });
|
|
18281
|
+
throw err;
|
|
18282
|
+
}
|
|
17538
18283
|
metrics.increment("github_search_errors_total", 1, { error_type: "malformed_json" });
|
|
17539
|
-
throw new Error("GitHub API returned a malformed JSON response (repo advisories).");
|
|
18284
|
+
throw new Error("GitHub API returned a malformed JSON response (repo advisories).", { cause: err });
|
|
17540
18285
|
}
|
|
17541
18286
|
let repoAdvisories = [];
|
|
17542
18287
|
if (isGitHubAdvisoryArray(data)) {
|
|
@@ -17602,10 +18347,14 @@ async function searchGitHubAdvisories(terms, options) {
|
|
|
17602
18347
|
}));
|
|
17603
18348
|
let data;
|
|
17604
18349
|
try {
|
|
17605
|
-
data = await response
|
|
17606
|
-
} catch {
|
|
18350
|
+
data = await readJsonCapped(response);
|
|
18351
|
+
} catch (err) {
|
|
18352
|
+
if (err instanceof BodyTooLargeError) {
|
|
18353
|
+
metrics.increment("github_search_errors_total", 1, { error_type: "body_too_large", endpoint: endpointType });
|
|
18354
|
+
throw err;
|
|
18355
|
+
}
|
|
17607
18356
|
metrics.increment("github_search_errors_total", 1, { error_type: "malformed_json", endpoint: endpointType });
|
|
17608
|
-
throw new Error(`GitHub API returned a malformed JSON response for term "${term}"
|
|
18357
|
+
throw new Error(`GitHub API returned a malformed JSON response for term "${term}".`, { cause: err });
|
|
17609
18358
|
}
|
|
17610
18359
|
let items = [];
|
|
17611
18360
|
if (isGitHubAdvisoryArray(data)) {
|
|
@@ -17615,7 +18364,7 @@ async function searchGitHubAdvisories(terms, options) {
|
|
|
17615
18364
|
} else if (isGitHubAdvisoryListResponse(data) && isArray(data.items)) {
|
|
17616
18365
|
items = data.items.filter(isGitHubAdvisoryRaw);
|
|
17617
18366
|
}
|
|
17618
|
-
metrics.increment(items.length > 0 ? "github_cache_hits_total" : "github_cache_misses_total", 1, {
|
|
18367
|
+
metrics.increment(items.length > 0 ? "github_cache_hits_total" : "github_cache_misses_total", 1, { endpoint: endpointType });
|
|
17619
18368
|
return items.map(mapGitHubAdvisory);
|
|
17620
18369
|
};
|
|
17621
18370
|
const settled = await Promise.allSettled(terms.map(fetchTerm));
|
|
@@ -17686,6 +18435,7 @@ init_logger();
|
|
|
17686
18435
|
init_constants();
|
|
17687
18436
|
init_circuit_breaker();
|
|
17688
18437
|
init_metrics();
|
|
18438
|
+
init_http_body();
|
|
17689
18439
|
|
|
17690
18440
|
// src/security/cvss.ts
|
|
17691
18441
|
var AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 };
|
|
@@ -17957,10 +18707,14 @@ async function searchOSV(terms, options) {
|
|
|
17957
18707
|
}
|
|
17958
18708
|
let data;
|
|
17959
18709
|
try {
|
|
17960
|
-
data = await response
|
|
17961
|
-
} catch {
|
|
18710
|
+
data = await readJsonCapped(response);
|
|
18711
|
+
} catch (err) {
|
|
18712
|
+
if (err instanceof BodyTooLargeError) {
|
|
18713
|
+
metrics.increment("osv_search_errors_total", 1, { error_type: "body_too_large" });
|
|
18714
|
+
throw err;
|
|
18715
|
+
}
|
|
17962
18716
|
metrics.increment("osv_search_errors_total", 1, { error_type: "malformed_json" });
|
|
17963
|
-
throw new Error(`OSV returned a malformed JSON response for "${term}"
|
|
18717
|
+
throw new Error(`OSV returned a malformed JSON response for "${term}".`, { cause: err });
|
|
17964
18718
|
}
|
|
17965
18719
|
let items;
|
|
17966
18720
|
if (isOsvVulnerability(data)) {
|
|
@@ -17972,7 +18726,7 @@ async function searchOSV(terms, options) {
|
|
|
17972
18726
|
return { vulns: [], skipped: false };
|
|
17973
18727
|
}
|
|
17974
18728
|
const endpoint = isIdLookup ? "vulns_by_id" : "query";
|
|
17975
|
-
metrics.increment(items.length > 0 ? "osv_cache_hits_total" : "osv_cache_misses_total", 1, {
|
|
18729
|
+
metrics.increment(items.length > 0 ? "osv_cache_hits_total" : "osv_cache_misses_total", 1, { endpoint });
|
|
17976
18730
|
const vulns = [];
|
|
17977
18731
|
for (const item of items) {
|
|
17978
18732
|
if (item.withdrawn) continue;
|
|
@@ -18565,6 +19319,7 @@ import { Value as Value8 } from "typebox/value";
|
|
|
18565
19319
|
init_logger();
|
|
18566
19320
|
init_circuit_breaker();
|
|
18567
19321
|
init_metrics();
|
|
19322
|
+
init_http_body();
|
|
18568
19323
|
var API_BASE = "https://api.stackexchange.com/2.3";
|
|
18569
19324
|
var StackExchangeClient = class {
|
|
18570
19325
|
_apiKey;
|
|
@@ -18629,8 +19384,9 @@ var StackExchangeClient = class {
|
|
|
18629
19384
|
});
|
|
18630
19385
|
let data;
|
|
18631
19386
|
try {
|
|
18632
|
-
data = await response
|
|
19387
|
+
data = await readJsonCapped(response);
|
|
18633
19388
|
} catch (parseErr) {
|
|
19389
|
+
if (parseErr instanceof BodyTooLargeError) throw parseErr;
|
|
18634
19390
|
if (!response.ok) throw new Error(`HTTP ${response.status} from Stack Exchange API`, { cause: parseErr });
|
|
18635
19391
|
throw parseErr;
|
|
18636
19392
|
}
|
|
@@ -19404,6 +20160,7 @@ init_retry_utils();
|
|
|
19404
20160
|
init_circuit_breaker();
|
|
19405
20161
|
init_logger();
|
|
19406
20162
|
init_metrics();
|
|
20163
|
+
init_http_body();
|
|
19407
20164
|
|
|
19408
20165
|
// src/youtube/potoken.ts
|
|
19409
20166
|
init_logger();
|
|
@@ -19636,7 +20393,7 @@ async function fetchOne(session, videoId, contentToken, lang, timeoutMs, signal,
|
|
|
19636
20393
|
});
|
|
19637
20394
|
throw new Error(`HTTP ${response.status} from timedtext endpoint`);
|
|
19638
20395
|
}
|
|
19639
|
-
const body = await response
|
|
20396
|
+
const body = await readTextCapped(response);
|
|
19640
20397
|
const text = parseJson3(body);
|
|
19641
20398
|
if (!text) {
|
|
19642
20399
|
throw new Error("Empty transcript body (bot-protection rejected the token).");
|
|
@@ -21025,7 +21782,7 @@ ${prompt}`);
|
|
|
21025
21782
|
modelRegistry: ctx.modelRegistry,
|
|
21026
21783
|
systemPrompt: prompt,
|
|
21027
21784
|
extensionCtx: ctx,
|
|
21028
|
-
excludeTools: this.options.excludeTools
|
|
21785
|
+
excludeTools: resolveExcludedTools(this.options.excludeTools),
|
|
21029
21786
|
config: this.config,
|
|
21030
21787
|
getGlobalState: () => ({
|
|
21031
21788
|
version: 1,
|
|
@@ -21354,6 +22111,9 @@ var DeepResearchOrchestrator = class {
|
|
|
21354
22111
|
excludeTools: this.options.excludeTools,
|
|
21355
22112
|
steeringMessages: steeringTexts
|
|
21356
22113
|
});
|
|
22114
|
+
} else if (this.currentRound >= maxRounds) {
|
|
22115
|
+
logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached \u2014 skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
|
|
22116
|
+
break;
|
|
21357
22117
|
} else {
|
|
21358
22118
|
const synthesisService2 = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
21359
22119
|
observer?.onEvaluationStart?.(this.currentRound);
|
|
@@ -21479,7 +22239,7 @@ var DeepResearchOrchestrator = class {
|
|
|
21479
22239
|
options: {
|
|
21480
22240
|
...this.options,
|
|
21481
22241
|
config: this.config,
|
|
21482
|
-
excludeTools: this.options.excludeTools
|
|
22242
|
+
excludeTools: resolveExcludedTools(this.options.excludeTools),
|
|
21483
22243
|
observer: this.observer
|
|
21484
22244
|
},
|
|
21485
22245
|
currentRound: this.currentRound,
|
|
@@ -21656,7 +22416,7 @@ var ResearchOrchestrationService = class {
|
|
|
21656
22416
|
const { ctx, query, depth = 0, observer, onUpdate, sessionId, researchId, config, excludeTools } = options;
|
|
21657
22417
|
const researchConfig = config || getConfig(ctx.cwd);
|
|
21658
22418
|
const configDisabledTools = researchConfig.DISABLED_TOOLS ? researchConfig.DISABLED_TOOLS.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
21659
|
-
const mergedExcludeTools =
|
|
22419
|
+
const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
|
|
21660
22420
|
const selectedModel = await this.resolveResearchModel(options);
|
|
21661
22421
|
logger.info(`[ResearchOrchestrationService] Using model: ${selectedModel.provider}/${selectedModel.id}`);
|
|
21662
22422
|
const researchStart = Date.now();
|
|
@@ -22178,42 +22938,6 @@ init_service_registry();
|
|
|
22178
22938
|
init_service_names();
|
|
22179
22939
|
var NO_SOURCES_NOTE = "CITED LINKS\n_No web sources were successfully retrieved for this report \u2014 searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._";
|
|
22180
22940
|
var UNVERIFIED_LINKS_NOTE = "_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented \u2014 check each one before relying on it._";
|
|
22181
|
-
var MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
22182
|
-
function rewriteCitationMarkers(text, remap, isDangling) {
|
|
22183
|
-
const inFence = fencedCodeMask(text);
|
|
22184
|
-
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
|
|
22185
|
-
const n = parseInt(digits, 10);
|
|
22186
|
-
const mapped = remap.get(n);
|
|
22187
|
-
if (mapped !== void 0) return `${pad}[${mapped}]`;
|
|
22188
|
-
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
22189
|
-
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
22190
|
-
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
22191
|
-
return text.slice(lineStart, offset).trim() === "" ? pad : "";
|
|
22192
|
-
});
|
|
22193
|
-
}
|
|
22194
|
-
function fencedCodeMask(text) {
|
|
22195
|
-
const ranges = [];
|
|
22196
|
-
let open4 = -1;
|
|
22197
|
-
let pos = 0;
|
|
22198
|
-
for (const line of text.split("\n")) {
|
|
22199
|
-
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
22200
|
-
if (open4 < 0) open4 = pos;
|
|
22201
|
-
else {
|
|
22202
|
-
ranges.push([open4, pos + line.length]);
|
|
22203
|
-
open4 = -1;
|
|
22204
|
-
}
|
|
22205
|
-
}
|
|
22206
|
-
pos += line.length + 1;
|
|
22207
|
-
}
|
|
22208
|
-
if (open4 >= 0) ranges.push([open4, text.length]);
|
|
22209
|
-
return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
22210
|
-
}
|
|
22211
|
-
function inInlineCodeSpan(text, offset) {
|
|
22212
|
-
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
22213
|
-
let ticks = 0;
|
|
22214
|
-
for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
|
|
22215
|
-
return ticks % 2 === 1;
|
|
22216
|
-
}
|
|
22217
22941
|
function redactUnverifiedProseUrls(text, trusted) {
|
|
22218
22942
|
if (trusted.size === 0) return text;
|
|
22219
22943
|
let removed = 0;
|
|
@@ -22257,6 +22981,23 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
|
22257
22981
|
this.sessions.set(sessionId, reports);
|
|
22258
22982
|
return reports;
|
|
22259
22983
|
}
|
|
22984
|
+
/**
|
|
22985
|
+
* Read a session's reports WITHOUT creating an entry for an unknown id.
|
|
22986
|
+
*
|
|
22987
|
+
* The read-only accessors below must not go through `getSessionReports`: that
|
|
22988
|
+
* is create-on-read, so a query for an id we have never seen inserts an empty
|
|
22989
|
+
* map — and at MAX_SESSIONS that insert evicts the current head. The LRU touch
|
|
22990
|
+
* protects sessions that are read or written, but an in-flight run whose
|
|
22991
|
+
* researchers are all still executing (reports stored early, nothing touched
|
|
22992
|
+
* since) is exactly the stalest key, so a stray read for an unknown session
|
|
22993
|
+
* could evict a live run's reports and silently shrink its final synthesis.
|
|
22994
|
+
*
|
|
22995
|
+
* Returns a shared empty map for unknown ids — callers here only read.
|
|
22996
|
+
*/
|
|
22997
|
+
peekSessionReports(sessionId) {
|
|
22998
|
+
return this.sessions.get(sessionId) ?? _ResearchSynthesisService.EMPTY_REPORTS;
|
|
22999
|
+
}
|
|
23000
|
+
static EMPTY_REPORTS = /* @__PURE__ */ new Map();
|
|
22260
23001
|
/**
|
|
22261
23002
|
* Store a researcher report
|
|
22262
23003
|
* @param sessionId - Session identifier
|
|
@@ -22270,13 +23011,13 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
|
22270
23011
|
* Get a report by ID
|
|
22271
23012
|
*/
|
|
22272
23013
|
getReport(sessionId, id) {
|
|
22273
|
-
return this.
|
|
23014
|
+
return this.peekSessionReports(sessionId).get(id);
|
|
22274
23015
|
}
|
|
22275
23016
|
/**
|
|
22276
23017
|
* Get all reports
|
|
22277
23018
|
*/
|
|
22278
23019
|
getAllReports(sessionId) {
|
|
22279
|
-
return new Map(this.
|
|
23020
|
+
return new Map(this.peekSessionReports(sessionId));
|
|
22280
23021
|
}
|
|
22281
23022
|
/**
|
|
22282
23023
|
* Get reports for a specific round
|
|
@@ -22284,7 +23025,7 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
|
22284
23025
|
getReportsForRound(sessionId, round) {
|
|
22285
23026
|
const roundReports = /* @__PURE__ */ new Map();
|
|
22286
23027
|
const prefix = `${round}.`;
|
|
22287
|
-
for (const [key, report] of this.
|
|
23028
|
+
for (const [key, report] of this.peekSessionReports(sessionId).entries()) {
|
|
22288
23029
|
if (key.startsWith(prefix)) {
|
|
22289
23030
|
roundReports.set(key, report);
|
|
22290
23031
|
}
|
|
@@ -22295,13 +23036,13 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
|
22295
23036
|
* Get total number of reports
|
|
22296
23037
|
*/
|
|
22297
23038
|
getReportCount(sessionId) {
|
|
22298
|
-
return this.
|
|
23039
|
+
return this.peekSessionReports(sessionId).size;
|
|
22299
23040
|
}
|
|
22300
23041
|
/**
|
|
22301
23042
|
* Check if there are any reports
|
|
22302
23043
|
*/
|
|
22303
23044
|
hasReports(sessionId) {
|
|
22304
|
-
return this.
|
|
23045
|
+
return this.peekSessionReports(sessionId).size > 0;
|
|
22305
23046
|
}
|
|
22306
23047
|
/**
|
|
22307
23048
|
* Clear reports for a session, or all reports if no sessionId provided
|
|
@@ -22320,7 +23061,7 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
|
|
|
22320
23061
|
* @returns Fallback synthesis string
|
|
22321
23062
|
*/
|
|
22322
23063
|
buildFallbackSynthesis(sessionId, currentRound = 0) {
|
|
22323
|
-
const reports = this.
|
|
23064
|
+
const reports = this.peekSessionReports(sessionId);
|
|
22324
23065
|
const reportCount = reports.size;
|
|
22325
23066
|
const roundInfo = currentRound > 0 ? ` (up to Round ${currentRound})` : "";
|
|
22326
23067
|
let synthesis = `Research Findings${roundInfo}
|
|
@@ -22356,7 +23097,7 @@ Research performed using ${modelId}`;
|
|
|
22356
23097
|
* @returns Synthesis with guaranteed and verified CITED LINKS section
|
|
22357
23098
|
*/
|
|
22358
23099
|
ensureCitedLinks(sessionId, synthesis) {
|
|
22359
|
-
const reports = this.
|
|
23100
|
+
const reports = this.peekSessionReports(sessionId);
|
|
22360
23101
|
let globalCitations = reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
|
|
22361
23102
|
if (reports.size > 0) {
|
|
22362
23103
|
const scraped = getScrapedLinks(sessionId);
|
|
@@ -22455,7 +23196,7 @@ ${guidanceSection}`;
|
|
|
22455
23196
|
extractAllCitations(sessionId) {
|
|
22456
23197
|
const seen = /* @__PURE__ */ new Set();
|
|
22457
23198
|
const allCitations = [];
|
|
22458
|
-
const reports = this.
|
|
23199
|
+
const reports = this.peekSessionReports(sessionId);
|
|
22459
23200
|
for (const report of reports.values()) {
|
|
22460
23201
|
const citations = parseCitations(report);
|
|
22461
23202
|
for (const cit of citations) {
|
|
@@ -23117,10 +23858,10 @@ init_url_utils();
|
|
|
23117
23858
|
// src/utils/research-export.ts
|
|
23118
23859
|
init_logger();
|
|
23119
23860
|
init_constants();
|
|
23120
|
-
import { promises as
|
|
23861
|
+
import { promises as fs15 } from "node:fs";
|
|
23121
23862
|
import { homedir as homedir4, tmpdir as tmpdir4, platform as platform3 } from "node:os";
|
|
23122
|
-
import { join as
|
|
23123
|
-
import { randomBytes as
|
|
23863
|
+
import { join as join19, resolve as resolve3, normalize, sep as sep2 } from "node:path";
|
|
23864
|
+
import { randomBytes as randomBytes6 } from "node:crypto";
|
|
23124
23865
|
var PREFERRED_SUBDIRS = [
|
|
23125
23866
|
"research",
|
|
23126
23867
|
"docs",
|
|
@@ -23183,16 +23924,16 @@ function isSafeToWriteDirectly(dir) {
|
|
|
23183
23924
|
async function findPreferredSubdir(cwd) {
|
|
23184
23925
|
let entries;
|
|
23185
23926
|
try {
|
|
23186
|
-
entries = await
|
|
23927
|
+
entries = await fs15.readdir(cwd);
|
|
23187
23928
|
} catch {
|
|
23188
23929
|
return null;
|
|
23189
23930
|
}
|
|
23190
23931
|
for (const target of PREFERRED_SUBDIRS) {
|
|
23191
23932
|
const match = entries.find((e) => e.toLowerCase() === target);
|
|
23192
23933
|
if (match === void 0) continue;
|
|
23193
|
-
const candidate =
|
|
23934
|
+
const candidate = join19(cwd, match);
|
|
23194
23935
|
try {
|
|
23195
|
-
const stat5 = await
|
|
23936
|
+
const stat5 = await fs15.stat(candidate);
|
|
23196
23937
|
if (stat5.isDirectory()) return candidate;
|
|
23197
23938
|
} catch {
|
|
23198
23939
|
}
|
|
@@ -23215,7 +23956,7 @@ function sanitizeQuery2(query) {
|
|
|
23215
23956
|
return query.toLowerCase().trim().slice(0, MAX_FILENAME_QUERY_LENGTH).replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
23216
23957
|
}
|
|
23217
23958
|
function generateHash() {
|
|
23218
|
-
return
|
|
23959
|
+
return randomBytes6(3).toString("hex");
|
|
23219
23960
|
}
|
|
23220
23961
|
async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
|
|
23221
23962
|
const sanitizedQuery = sanitizeQuery2(query);
|
|
@@ -23224,7 +23965,7 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
|
|
|
23224
23965
|
if (explicitDir && explicitDir.trim()) {
|
|
23225
23966
|
targetDir = resolve3(explicitDir.trim());
|
|
23226
23967
|
try {
|
|
23227
|
-
await
|
|
23968
|
+
await fs15.mkdir(targetDir, { recursive: true });
|
|
23228
23969
|
} catch (error) {
|
|
23229
23970
|
logger.error(`[export] Could not create export dir "${targetDir}":`, error);
|
|
23230
23971
|
return null;
|
|
@@ -23235,9 +23976,9 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
|
|
|
23235
23976
|
for (let attempt = 0; attempt < MAX_EXPORT_RETRIES; attempt++) {
|
|
23236
23977
|
const hash = generateHash();
|
|
23237
23978
|
const filename = `${baseFilename}-${hash}.md`;
|
|
23238
|
-
const filepath =
|
|
23979
|
+
const filepath = join19(targetDir, filename);
|
|
23239
23980
|
try {
|
|
23240
|
-
await
|
|
23981
|
+
await fs15.writeFile(filepath, result, { flag: "wx" });
|
|
23241
23982
|
logger.log(`[export] Research report saved to: ${filepath}`);
|
|
23242
23983
|
return filepath;
|
|
23243
23984
|
} catch (error) {
|
|
@@ -23255,8 +23996,8 @@ function appendExportMessage(result, filepath, totalCost) {
|
|
|
23255
23996
|
---
|
|
23256
23997
|
|
|
23257
23998
|
Research report saved to: ${filepath}`;
|
|
23258
|
-
if (totalCost !== void 0
|
|
23259
|
-
const costStr = totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
|
|
23999
|
+
if (totalCost !== void 0) {
|
|
24000
|
+
const costStr = totalCost === 0 ? "$0.00" : totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
|
|
23260
24001
|
footer += `
|
|
23261
24002
|
Total cost: ${costStr}`;
|
|
23262
24003
|
}
|
|
@@ -23270,7 +24011,7 @@ import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
|
|
|
23270
24011
|
function readPkgVersion() {
|
|
23271
24012
|
try {
|
|
23272
24013
|
const pkgPath = fileURLToPath5(new URL("../package.json", import.meta.url));
|
|
23273
|
-
const pkg = JSON.parse(
|
|
24014
|
+
const pkg = JSON.parse(fs17.readFileSync(pkgPath, "utf-8"));
|
|
23274
24015
|
return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
|
|
23275
24016
|
} catch {
|
|
23276
24017
|
return "0.0.0";
|
|
@@ -23293,8 +24034,28 @@ var EXIT = {
|
|
|
23293
24034
|
* again shortly" apart from "this run crashed" and back off instead of
|
|
23294
24035
|
* re-running a broken command.
|
|
23295
24036
|
*/
|
|
23296
|
-
TEMPFAIL: 75
|
|
24037
|
+
TEMPFAIL: 75,
|
|
24038
|
+
/**
|
|
24039
|
+
* The run was CANCELLED via a programmatic abort (the caller's AbortSignal),
|
|
24040
|
+
* with no OS signal involved. 130 — 128+SIGINT — is the conventional
|
|
24041
|
+
* "interrupted" code and the natural default when there is no signal number to
|
|
24042
|
+
* derive one from.
|
|
24043
|
+
*
|
|
24044
|
+
* A cancellation caused by an actual SIGNAL does NOT use this constant: see
|
|
24045
|
+
* exitCodeForSignal below, which reports the true 128+N.
|
|
24046
|
+
*
|
|
24047
|
+
* Distinct from SOFTWARE for the same reason TEMPFAIL is: a deliberate stop is
|
|
24048
|
+
* not a fault. Reported as SOFTWARE (70), a cancel matched the skill contract's
|
|
24049
|
+
* "runtime error — suggest one retry for transient failures", so an agent
|
|
24050
|
+
* relaying the result would offer to re-run the very research the user had just
|
|
24051
|
+
* interrupted, and a --json consumer could not tell a cancel from a crash.
|
|
24052
|
+
*/
|
|
24053
|
+
CANCELLED: 130
|
|
23297
24054
|
};
|
|
24055
|
+
function exitCodeForSignal(sig) {
|
|
24056
|
+
const signum = os7.constants.signals[sig];
|
|
24057
|
+
return typeof signum === "number" ? 128 + signum : EXIT.CANCELLED;
|
|
24058
|
+
}
|
|
23298
24059
|
function toStderr(s) {
|
|
23299
24060
|
process.stderr.write(s);
|
|
23300
24061
|
}
|
|
@@ -23339,10 +24100,16 @@ function makeProgressObserver() {
|
|
|
23339
24100
|
toStderr(` \u2022 evaluator: ${data?.action}
|
|
23340
24101
|
`);
|
|
23341
24102
|
break;
|
|
23342
|
-
case "tokens_consumed":
|
|
23343
|
-
|
|
24103
|
+
case "tokens_consumed": {
|
|
24104
|
+
const consumed = Number(data?.tokens ?? 0);
|
|
24105
|
+
const costVal = typeof data?.cost === "number" ? data.cost : void 0;
|
|
24106
|
+
if (consumed > 0 || costVal !== void 0) {
|
|
24107
|
+
const costPart = costVal === void 0 ? "" : ` ($${costVal.toFixed(4)})`;
|
|
24108
|
+
toStderr(` \u2022 tokens: ${consumed}${costPart}
|
|
23344
24109
|
`);
|
|
24110
|
+
}
|
|
23345
24111
|
break;
|
|
24112
|
+
}
|
|
23346
24113
|
case "complete":
|
|
23347
24114
|
toStderr(` \u2022 complete (${data?.result?.length ?? 0} chars)
|
|
23348
24115
|
`);
|
|
@@ -23362,16 +24129,22 @@ function bridgeConfigEnv(explicitConfigPath) {
|
|
|
23362
24129
|
const cliOverlayPath = getInterfaceEnvFilePath("cli");
|
|
23363
24130
|
let loaded = false;
|
|
23364
24131
|
const merged = {};
|
|
24132
|
+
const fromExplicitBase = /* @__PURE__ */ new Set();
|
|
23365
24133
|
for (const filePath of [basePath, cliOverlayPath]) {
|
|
23366
24134
|
try {
|
|
23367
|
-
if (!
|
|
23368
|
-
|
|
24135
|
+
if (!fs17.existsSync(filePath)) continue;
|
|
24136
|
+
const parsed = parseDotEnv(fs17.readFileSync(filePath, "utf-8"));
|
|
24137
|
+
Object.assign(merged, parsed);
|
|
24138
|
+
for (const k of Object.keys(parsed)) {
|
|
24139
|
+
if (explicitConfigPath !== void 0 && filePath === basePath) fromExplicitBase.add(k);
|
|
24140
|
+
else fromExplicitBase.delete(k);
|
|
24141
|
+
}
|
|
23369
24142
|
loaded = true;
|
|
23370
24143
|
} catch {
|
|
23371
24144
|
}
|
|
23372
24145
|
}
|
|
23373
24146
|
for (const [k, v] of Object.entries(merged)) {
|
|
23374
|
-
if (isProjectScopedKey(k)) continue;
|
|
24147
|
+
if (isProjectScopedKey(k) && !fromExplicitBase.has(k)) continue;
|
|
23375
24148
|
if (process.env[k] === void 0 && (k.startsWith("PI_RESEARCH_") || k === "STACKEXCHANGE_API_KEY" || k === "GITHUB_TOKEN" || k === "NVD_API_KEY")) {
|
|
23376
24149
|
process.env[k] = v;
|
|
23377
24150
|
}
|
|
@@ -23393,7 +24166,7 @@ function resolvedConfigPaths() {
|
|
|
23393
24166
|
}
|
|
23394
24167
|
function fileExists(p) {
|
|
23395
24168
|
try {
|
|
23396
|
-
return
|
|
24169
|
+
return fs17.existsSync(p);
|
|
23397
24170
|
} catch {
|
|
23398
24171
|
return false;
|
|
23399
24172
|
}
|
|
@@ -23864,18 +24637,26 @@ async function reportError(err, what, json) {
|
|
|
23864
24637
|
const lower = msg2.toLowerCase();
|
|
23865
24638
|
const isTaggedResearchStop = err?.code === RESEARCH_STOPPED_ERROR_CODE;
|
|
23866
24639
|
const isCapacity = err instanceof Error && err.name === "ResearchRunCapacityError";
|
|
23867
|
-
const
|
|
23868
|
-
const
|
|
23869
|
-
const
|
|
24640
|
+
const isCancellation = cancellationRequested || err instanceof Error && /^(aborted|research (aborted|cancelled))$/i.test(msg2);
|
|
24641
|
+
const isConfigError = !isTaggedResearchStop && !isCapacity && !isCancellation && (lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key"));
|
|
24642
|
+
const isRateLimit = !isCancellation && (lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests"));
|
|
24643
|
+
const exitCode = isCancellation ? cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED : isCapacity ? EXIT.TEMPFAIL : isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
23870
24644
|
if (json) {
|
|
23871
24645
|
const payload = { ok: false, error: msg2, exitCode };
|
|
23872
|
-
if (isCapacity || isRateLimit) payload["retryable"] = true;
|
|
24646
|
+
if ((isCapacity || isRateLimit) && !isCancellation) payload["retryable"] = true;
|
|
24647
|
+
if (isCancellation) payload["cancelled"] = true;
|
|
23873
24648
|
if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
|
|
23874
24649
|
payload["stack"] = err.stack;
|
|
23875
24650
|
}
|
|
23876
24651
|
toStdout(pretty(payload));
|
|
23877
24652
|
return exitCode;
|
|
23878
24653
|
}
|
|
24654
|
+
if (isCancellation) {
|
|
24655
|
+
toStderr(`
|
|
24656
|
+
pi-research ${what} cancelled.
|
|
24657
|
+
`);
|
|
24658
|
+
return exitCode;
|
|
24659
|
+
}
|
|
23879
24660
|
if (isCapacity) {
|
|
23880
24661
|
toStderr(`
|
|
23881
24662
|
Error: pi-research ${what} did not start: ${msg2}
|
|
@@ -23982,10 +24763,14 @@ CONFIGURE
|
|
|
23982
24763
|
Run \`${BINARY_NAME} status\` to see exactly what is detected.
|
|
23983
24764
|
|
|
23984
24765
|
EXIT CODES
|
|
23985
|
-
0
|
|
23986
|
-
64
|
|
23987
|
-
|
|
23988
|
-
|
|
24766
|
+
0 success
|
|
24767
|
+
64 bad arguments
|
|
24768
|
+
75 at capacity \u2014 every concurrent run slot is busy; nothing is broken, retry later
|
|
24769
|
+
78 not configured \u2014 message prints the exact locations to fix
|
|
24770
|
+
(on \`knowledge\`, also returned when the store is deliberately disabled)
|
|
24771
|
+
130 cancelled \u2014 Ctrl-C; nothing failed, do not auto-retry
|
|
24772
|
+
(other stop signals report POSIX 128+N: 143 SIGTERM, 129 SIGHUP, 131 SIGQUIT)
|
|
24773
|
+
70 runtime error (network, provider, internal)
|
|
23989
24774
|
`;
|
|
23990
24775
|
}
|
|
23991
24776
|
function parseArgs(argv) {
|
|
@@ -24180,19 +24965,35 @@ var flushAndExit = (code) => {
|
|
|
24180
24965
|
});
|
|
24181
24966
|
};
|
|
24182
24967
|
var activeResearchAbortController = null;
|
|
24968
|
+
var cancellationRequested = false;
|
|
24969
|
+
var cancellationSignal = null;
|
|
24970
|
+
function _resetCancellationLatchForTests() {
|
|
24971
|
+
cancellationRequested = false;
|
|
24972
|
+
cancellationSignal = null;
|
|
24973
|
+
}
|
|
24974
|
+
function _markCancellationForTests(sig) {
|
|
24975
|
+
cancellationRequested = true;
|
|
24976
|
+
cancellationSignal = sig ?? null;
|
|
24977
|
+
}
|
|
24183
24978
|
async function main(argv) {
|
|
24184
24979
|
let _signalCleanupDone = false;
|
|
24185
24980
|
const onSignal = (sig) => {
|
|
24186
24981
|
if (_signalCleanupDone) return;
|
|
24187
24982
|
_signalCleanupDone = true;
|
|
24983
|
+
cancellationRequested = true;
|
|
24984
|
+
cancellationSignal = sig;
|
|
24188
24985
|
toStderr(`
|
|
24189
24986
|
[pi-research] ${sig} \u2014 cleaning up\u2026
|
|
24190
24987
|
`);
|
|
24191
24988
|
activeResearchAbortController?.abort();
|
|
24192
|
-
void safeShutdown().finally(() => flushAndExit(
|
|
24989
|
+
void safeShutdown().finally(() => flushAndExit(exitCodeForSignal(sig)));
|
|
24193
24990
|
};
|
|
24194
24991
|
process.once("SIGINT", () => onSignal("SIGINT"));
|
|
24195
24992
|
process.once("SIGTERM", () => onSignal("SIGTERM"));
|
|
24993
|
+
process.once("SIGHUP", () => onSignal("SIGHUP"));
|
|
24994
|
+
if (process.platform === "win32") {
|
|
24995
|
+
process.once("SIGBREAK", () => onSignal("SIGBREAK"));
|
|
24996
|
+
}
|
|
24196
24997
|
let parsed;
|
|
24197
24998
|
try {
|
|
24198
24999
|
parsed = parseArgs(argv);
|
|
@@ -24239,8 +25040,8 @@ ${buildHelp()}`);
|
|
|
24239
25040
|
var _isMain = (() => {
|
|
24240
25041
|
try {
|
|
24241
25042
|
if (!process.argv[1]) return false;
|
|
24242
|
-
const modulePath =
|
|
24243
|
-
const invokedPath =
|
|
25043
|
+
const modulePath = fs17.realpathSync(path18.resolve(fileURLToPath5(import.meta.url)));
|
|
25044
|
+
const invokedPath = fs17.realpathSync(path18.resolve(process.argv[1]));
|
|
24244
25045
|
return modulePath === invokedPath;
|
|
24245
25046
|
} catch {
|
|
24246
25047
|
return false;
|
|
@@ -24257,7 +25058,10 @@ Error: fatal: ${err instanceof Error ? err.message : String(err)}
|
|
|
24257
25058
|
export {
|
|
24258
25059
|
EXIT,
|
|
24259
25060
|
UsageError,
|
|
25061
|
+
_markCancellationForTests,
|
|
25062
|
+
_resetCancellationLatchForTests,
|
|
24260
25063
|
bridgeConfigEnv,
|
|
25064
|
+
exitCodeForSignal,
|
|
24261
25065
|
parseArgs,
|
|
24262
25066
|
reportError
|
|
24263
25067
|
};
|