合并 master 到 Pingora 网关分支

保留 Pingora 网关发布与巡检能力

合入外部生成 worker 与 controller 运维链路

对齐 SpacetimeDB 2.5.0 依赖锁定

更新项目共享记忆与生产运维文档
This commit is contained in:
2026-06-17 21:05:57 +08:00
523 changed files with 55530 additions and 16716 deletions
+55 -15
View File
@@ -861,6 +861,46 @@ const checks = [
reason:
'Server-Provision 必须只把 Pingora 直连 drop-in 安装为人工启用模板。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'genarrative-external-generation-controller.service',
reason: 'Server-Provision 必须安装并启用外部生成 worker controller。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'genarrative-external-generation-worker@1.service',
reason: 'Server-Provision 必须启用外部生成保底 worker 实例。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'ensure_default_worker_service',
reason: 'API Deploy 必须在缺少 worker 实例时补启动默认外部生成 worker。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'wait_for_worker_services',
reason: 'API Deploy 必须等待外部生成 worker 实例 active。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'wait_for_worker_controller_service',
reason: 'API Deploy 必须重启并验活外部生成 worker controller。',
},
{
file: 'deploy/systemd/genarrative-external-generation-worker@.service',
includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-worker',
reason: '外部生成 worker 模板必须作为独立 worker 进程角色运行。',
},
{
file: 'deploy/systemd/genarrative-external-generation-controller.service',
includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-controller',
reason: '外部生成 worker controller 必须作为独立进程角色运行。',
},
{
file: 'scripts/ops/production-health-patrol.mjs',
includes: 'checkActiveWorkerInstances',
reason: '生产健康巡检必须检查至少一个外部生成 worker 实例 active。',
},
{
file: 'scripts/build-production-release.sh',
includes: 'production-health-patrol.mjs',
@@ -1810,12 +1850,12 @@ const checks = [
reason: '生产运维文档必须说明证据验真默认闭集归档口径。',
},
{
file: '.hermes/shared-memory/decision-log.md',
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'manifest.files` 视为闭集',
reason: '团队共享决策必须记录 Pingora 证据验真闭集归档口径。',
},
{
file: '.hermes/shared-memory/pitfalls.md',
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'manifest.files` 视为闭集',
reason: '团队共享踩坑必须记录 Pingora 证据目录不能夹带未登记条目。',
},
@@ -4972,37 +5012,37 @@ const checks = [
'Nginx README 必须说明 direct live 静态响应头摘要缺关键证据时会阻断证据包。',
},
{
file: '.hermes/shared-memory/decision-log.md',
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'API / WSS 检查不落原始响应头',
reason:
'团队共享决策必须记录 direct live 只归档静态白名单响应头。',
},
{
file: '.hermes/shared-memory/decision-log.md',
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'directLiveStaticHeaders',
reason:
'团队共享决策必须记录切流 manifest 提升 direct live 静态响应头摘要。',
},
{
file: '.hermes/shared-memory/decision-log.md',
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: '静态头摘要缺少缓存头、校验头、Range `206 + Content-Range`、ETag 304 / Last-Modified 304 证据时整包记为 `CRITICAL`',
reason:
'团队共享决策必须记录静态响应头摘要不完整会阻断证据包。',
},
{
file: '.hermes/shared-memory/pitfalls.md',
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: '不要把 `direct-live.json` 当成只有状态码的摘要',
reason:
'团队共享踩坑必须提醒 direct live JSON 需要可复盘静态响应头证据。',
},
{
file: '.hermes/shared-memory/pitfalls.md',
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'manifest.summary.directLiveStaticHeaders',
reason:
'团队共享踩坑必须提醒切流 manifest 需要可快速复盘静态响应头摘要。',
},
{
file: '.hermes/shared-memory/pitfalls.md',
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: '摘要缺少缓存头、校验头、Range `206 + Content-Range`、ETag 304 或 Last-Modified 304 证据,证据包会直接记为 `CRITICAL`',
reason:
'团队共享踩坑必须提醒静态响应头摘要不完整会阻断证据包。',
@@ -5211,7 +5251,7 @@ const checks = [
reason: 'Pingora 试点文档不能继续把 Brotli 取舍留成待办。',
},
{
file: '.hermes/shared-memory/pitfalls.md',
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'Pingora Brotli 不能只看 Content-Encoding',
reason: '团队共享踩坑必须记录 Pingora Brotli 端到端解压风险。',
},
@@ -5314,12 +5354,12 @@ const checks = [
reason: '生产运维文档必须展示 Pingora 直连 access log 落盘校验参数。',
},
{
file: '.hermes/shared-memory/decision-log.md',
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: '直连日志证据补充',
reason: '团队共享决策必须记录 Pingora 直连 access log 证据门禁。',
},
{
file: '.hermes/shared-memory/pitfalls.md',
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: '--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log',
reason: '团队共享踩坑必须记录 Pingora 直连 access log 参数。',
},
@@ -5355,23 +5395,23 @@ const checks = [
reason: '生产运维文档必须明确多实例 Pingora 与进程内保护的启动 / preflight 边界。',
},
{
file: '.hermes/shared-memory/decision-log.md',
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'GENARRATIVE_PINGORA_GATEWAY_INSTANCE_COUNT>1',
reason: '团队共享决策必须记录 Pingora 多实例接流保护边界。',
},
{
file: '.hermes/shared-memory/decision-log.md',
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: '禁止继续执行部署工作区根部脚本',
reason: '团队共享决策必须记录 API Deploy 不再执行 workspace 根部脚本。',
},
{
file: '.hermes/shared-memory/pitfalls.md',
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'API deploy 脚本本身也不能继续用部署工作区根部',
reason:
'团队共享踩坑必须记录 workspace 根部 deploy 脚本会掩盖发布包布局问题。',
},
{
file: '.hermes/shared-memory/pitfalls.md',
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: '备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底',
reason: '团队共享踩坑必须记录备份/巡检脚本也不能使用 workspace fallback。',
},
+3 -3
View File
@@ -37,11 +37,11 @@ chmod +x "${TARGET_BIN_DIR}/otelcol-contrib"
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF'
#!/usr/bin/env bash
echo "spacetimedb-cli 2.4.1"
echo "spacetimedb-cli 2.5.0"
EOF
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF'
#!/usr/bin/env bash
echo "spacetimedb-standalone 2.4.1"
echo "spacetimedb-standalone 2.5.0"
EOF
chmod +x \
"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \
@@ -58,7 +58,7 @@ if ! (
OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \
OTELCOL_VERSION="0.151.0" \
SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \
SPACETIME_EXPECTED_VERSION="2.4.1" \
SPACETIME_EXPECTED_VERSION="2.5.0" \
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
>"${OUTPUT_LOG}" 2>&1
); then
+2 -2
View File
@@ -475,14 +475,14 @@ function loadBaseSources(baseRef) {
function getChangedFiles(baseRef) {
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
const untrackedOutput =
const untrackedModuleOutput =
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
const untrackedBindingsOutput =
tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? '';
return new Set(
[
...diffOutput.split(/\u0000/u),
...untrackedOutput.split(/\u0000/u),
...untrackedModuleOutput.split(/\u0000/u),
...untrackedBindingsOutput.split(/\u0000/u),
]
.map(normalizePath)
@@ -7,7 +7,7 @@ const reportPath = join(repoRoot, '.tmp', 'VN11_NEGATIVE_SCAN_REPORT_2026-05-07.
const documentTargets = [
'docs',
'.hermes/shared-memory',
'docs/project-memory/shared-memory',
];
const visualNovelImplementationTargets = [
@@ -202,7 +202,7 @@ const reportLines = [
'## 扫描范围',
'',
'- 视觉小说工程代码:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
'- 文档与共享记忆:`docs/`、`.hermes/shared-memory/`',
'- 文档与共享记忆:`docs/`、`docs/project-memory/shared-memory/`',
'- 外部平台误入复核:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
'',
'## 扫描结论',
File diff suppressed because it is too large Load Diff
+154 -1
View File
@@ -5,10 +5,11 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
说明:
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
默认同时重启外部生成 worker controller 和已加载的 worker 实例;未启用 worker 单元时会自动跳过。
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
若发布包包含 pingora-gateway,部署脚本会在 current 链接切换后仅对已 active 的 Pingora 影子服务执行 try-restart 并复核 active;未运行时不会主动拉起。
失败时保留维护模式。
@@ -289,6 +290,136 @@ if (requirePingora) {
NODE
}
list_worker_services() {
local pattern="$1"
if [[ -z "${pattern}" ]]; then
return 0
fi
systemctl list-units --all --plain --no-legend "${pattern}" 2>/dev/null | awk '{print $1}' | sort -u
}
ensure_default_worker_service() {
local pattern="$1"
local default_service="genarrative-external-generation-worker@1.service"
local template_service="genarrative-external-generation-worker@.service"
local services=()
if [[ -z "${pattern}" ]]; then
return 0
fi
if [[ "${pattern}" != "genarrative-external-generation-worker@*.service" ]]; then
return 0
fi
if ! systemctl cat "${template_service}" >/dev/null 2>&1; then
echo "[production-api-deploy] 缺少外部生成 worker systemd 模板: ${template_service}" >&2
return 1
fi
mapfile -t services < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -gt 0 ]]; then
return 0
fi
echo "[production-api-deploy] 未发现外部生成 worker 实例,启用并启动默认实例: ${default_service}"
systemctl enable --now "${default_service}"
}
restart_worker_services() {
local pattern="$1"
local services=()
if [[ -z "${pattern}" ]]; then
echo "[production-api-deploy] 跳过外部生成 worker 重启。"
return 0
fi
ensure_default_worker_service "${pattern}"
mapfile -t services < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -eq 0 ]]; then
echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2
return 1
fi
echo "[production-api-deploy] 重启外部生成 worker: ${services[*]}"
systemctl restart "${services[@]}"
}
wait_for_worker_services() {
local pattern="$1"
local services=()
local all_active
if [[ -z "${pattern}" ]]; then
return 0
fi
mapfile -t services < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -eq 0 ]]; then
echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2
return 1
fi
echo "[production-api-deploy] 等待外部生成 worker active: ${services[*]}"
for _ in {1..30}; do
all_active=1
for service in "${services[@]}"; do
if ! systemctl is-active --quiet "${service}"; then
all_active=0
break
fi
done
if [[ "${all_active}" -eq 1 ]]; then
return 0
fi
sleep 2
done
systemctl --no-pager --full status "${services[@]}" || true
echo "[production-api-deploy] 外部生成 worker 未在超时时间内进入 active,发布失败。" >&2
return 1
}
ensure_worker_controller_service() {
local service="$1"
if [[ -z "${service}" ]]; then
return 0
fi
if ! systemctl cat "${service}" >/dev/null 2>&1; then
echo "[production-api-deploy] 缺少外部生成 worker controller systemd 单元: ${service}" >&2
return 1
fi
echo "[production-api-deploy] 启用并重启外部生成 worker controller: ${service}"
systemctl enable "${service}"
systemctl restart "${service}"
}
wait_for_worker_controller_service() {
local service="$1"
if [[ -z "${service}" ]]; then
return 0
fi
echo "[production-api-deploy] 等待外部生成 worker controller active: ${service}"
for _ in {1..30}; do
if systemctl is-active --quiet "${service}"; then
return 0
fi
sleep 2
done
systemctl --no-pager --full status "${service}" || true
echo "[production-api-deploy] 外部生成 worker controller 未在超时时间内进入 active,发布失败。" >&2
return 1
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR=""
VERSION=""
@@ -296,6 +427,8 @@ RELEASE_ROOT="/opt/genarrative/releases"
CURRENT_LINK="/opt/genarrative/current"
SERVICE_NAME="genarrative-api.service"
PINGORA_SERVICE_NAME="genarrative-pingora-gateway.service"
WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service"
WORKER_CONTROLLER_SERVICE="genarrative-external-generation-controller.service"
HEALTH_URL="http://127.0.0.1:8082/readyz"
API_ENV_FILE="/etc/genarrative/api-server.env"
DATABASE=""
@@ -335,6 +468,22 @@ while [[ $# -gt 0 ]]; do
PINGORA_SERVICE_NAME="${2:?缺少 --pingora-service 的值}"
shift 2
;;
--worker-service-pattern)
WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}"
shift 2
;;
--no-worker-services)
WORKER_SERVICE_PATTERN=""
shift
;;
--worker-controller-service)
WORKER_CONTROLLER_SERVICE="${2:?缺少 --worker-controller-service 的值}"
shift 2
;;
--no-worker-controller)
WORKER_CONTROLLER_SERVICE=""
shift
;;
--health-url)
HEALTH_URL="${2:?缺少 --health-url 的值}"
shift 2
@@ -634,6 +783,10 @@ fi
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
restart_worker_services "${WORKER_SERVICE_PATTERN}"
wait_for_worker_services "${WORKER_SERVICE_PATTERN}"
ensure_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
wait_for_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
for _ in {1..30}; do
+46 -9
View File
@@ -37,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
const SERVICE_ALIASES = new Map([
@@ -399,6 +400,39 @@ function requireCommand(command) {
}
}
function isSccacheRustcWrapper(value) {
const wrapper = String(value ?? '').trim();
if (!wrapper) {
return false;
}
const command = wrapper.split(/[\\/]/).pop()?.toLowerCase();
return command === 'sccache' || command === 'sccache.exe';
}
function buildLocalRustProcessEnv(env, options = {}) {
const mergedEnv = {...env};
const wrappers = [
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
].filter(Boolean);
const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper));
if (customWrapper) {
mergedEnv.RUSTC_WRAPPER = customWrapper;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
return mergedEnv;
}
mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
if (options.log !== false) {
console.warn(
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
);
}
return mergedEnv;
}
function readWorkspaceSpacetimeVersion() {
const manifestText = readFileSync(manifestPath, 'utf8');
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
@@ -451,7 +485,7 @@ function assertSpacetimeToolVersionMatchesWorkspace({
[
`本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
`执行 spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion} 后重新运行本命令。`,
`先直接升级并切换到锁定版本: spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion},然后重新运行本命令。`,
].join(''),
);
}
@@ -479,9 +513,11 @@ function assertReusableSpacetimeProcessVersionMatchesWorkspace({
[
`正在运行的本地 SpacetimeDB standalone 版本 ${recordedVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
'请停止当前 SpacetimeDB 进程,执行 spacetime version use ',
'请停止当前 SpacetimeDB 进程,先直接升级并切换到锁定版本: spacetime version install ',
workspaceVersion,
' 后重新运行 npm run dev:spacetime。',
' && spacetime version use ',
workspaceVersion,
',然后重新运行 npm run dev:spacetime。',
].join(''),
);
}
@@ -776,7 +812,7 @@ class DevRunner {
this.writeDevStackState();
}
async prepareLinuxPortRange(command) {
async prepareLinuxPortRange() {
if (process.platform !== 'linux') {
return;
}
@@ -1228,7 +1264,7 @@ class DevRunner {
}
async publishSpacetimeModule() {
const env = {...this.baseEnv};
const env = buildLocalRustProcessEnv(this.baseEnv);
this.prepareMigrationBootstrapSecret(env);
const args = buildSpacetimePublishArgs({
@@ -1291,7 +1327,7 @@ class DevRunner {
await this.ensureApiServerSpacetimeToken();
const mergedEnv = buildApiServerProcessEnv({
baseEnv: this.baseEnv,
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
options: this.options,
state: this.state,
});
@@ -2124,19 +2160,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
}
export {
DevRunner,
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
isSpacetimePublishPermissionError,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseSpacetimeToolVersion,
parseArgs,
parseSpacetimeToolVersion,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
};
+38 -8
View File
@@ -5,19 +5,20 @@ import {join} from 'node:path';
import {afterEach, describe, expect, test, vi} from 'vitest';
import {
DevRunner,
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseSpacetimeToolVersion,
parseArgs,
parseSpacetimeToolVersion,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
} from './dev.mjs';
@@ -185,6 +186,35 @@ describe('dev scheduler api-server env', () => {
});
});
describe('dev scheduler Rust build env', () => {
test('local dev Rust env bypasses project sccache wrapper', () => {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: '/usr/bin/sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{log: false},
);
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
});
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'custom-wrapper',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{log: false},
);
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
});
});
describe('dev scheduler stack state file', () => {
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
@@ -404,24 +434,24 @@ describe('dev scheduler watch routing', () => {
describe('dev scheduler spacetime refresh', () => {
test('解析 Cargo 精确版本要求时用于 CLI 校验的版本号不带等号', () => {
expect(normalizeCargoVersionRequirement('=2.4.1')).toBe('2.4.1');
expect(normalizeCargoVersionRequirement('2.4.1')).toBe('2.4.1');
expect(normalizeCargoVersionRequirement('=2.5.0')).toBe('2.5.0');
expect(normalizeCargoVersionRequirement('2.5.0')).toBe('2.5.0');
});
test('解析 spacetime --version 输出里的 tool version', () => {
const version = parseSpacetimeToolVersion(`
A new version of SpacetimeDB is available: v2.4.1 (current: v2.4.0)
spacetimedb tool version 2.4.1; spacetimedb-lib version 2.4.1;
A new version of SpacetimeDB is available: v2.5.0 (current: v2.4.1)
spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
`);
expect(version).toBe('2.4.1');
expect(version).toBe('2.5.0');
});
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
expect(() =>
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: '2.1.0',
workspaceVersion: '2.4.1',
workspaceVersion: '2.5.0',
}),
).toThrow('procedure 返回值 BSATN 反序列化失败');
});
+118 -3
View File
@@ -4,6 +4,8 @@ set -euo pipefail
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
WORKER_ENV_FILE="${WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}"
CONTROLLER_ENV_FILE="${CONTROLLER_ENV_FILE:-/etc/genarrative/external-generation-controller.env}"
GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}"
GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}"
GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}"
@@ -242,6 +244,47 @@ sync_otelcol_install() {
fi
}
ensure_otelcol_runtime() {
if [[ "${ENABLE_OTELCOL:-true}" != "true" ]]; then
return
fi
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ ensure system user/group otelcol"
echo "+ install -d -m 0755 -o otelcol -g otelcol /var/lib/otelcol"
echo "+ install -d -m 0755 -o root -g root /etc/otelcol"
echo "+ install -d -m 0755 -o genarrative -g genarrative /var/log/genarrative"
echo "+ install -m 0644 deploy/otelcol/genarrative-debug.yaml /etc/otelcol/genarrative-debug.yaml"
return
fi
if ! getent group otelcol >/dev/null 2>&1; then
groupadd --system otelcol
fi
if ! id otelcol >/dev/null 2>&1; then
useradd --system --gid otelcol --home-dir /var/lib/otelcol --shell /usr/sbin/nologin otelcol
fi
install -d -m 0755 -o otelcol -g otelcol /var/lib/otelcol
install -d -m 0755 -o root -g root /etc/otelcol
install -d -m 0755 -o genarrative -g genarrative /var/log/genarrative
install -m 0644 deploy/otelcol/genarrative-debug.yaml /etc/otelcol/genarrative-debug.yaml
chown root:root /etc/otelcol/genarrative-debug.yaml
}
stamp_database_backup_timer_now() {
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ install -d -m 0755 /var/lib/systemd/timers"
echo "+ touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer"
return
fi
install -d -m 0755 /var/lib/systemd/timers
# 避免 provision 在当天 03:20 之后启动 timer 时因 Persistent=true 立刻补跑冷备份、
# 进而在初始化/发布窗口中意外停止 spacetimedb.service。
touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer
}
sync_spacetime_install() {
local root_dir="$1"
local target_bin_dir="${root_dir}/bin/current"
@@ -458,6 +501,7 @@ ensure_spacetime_owner_client_token() {
echo "[server-provision] 已生成 SpacetimeDB client identity 并写入 GENARRATIVE_SPACETIME_TOKEN: ${identity_preview}..."
fi
# 中文注释:这里是 provision 内部为 spacetimedb 运行用户隔离 CLI 登录态的受控用法,不作为人工 spacetime 命令示例。
if ! login_output="$(runuser -u spacetimedb -- "${cli_path}" --root-dir "${SPACETIME_ROOT}" login --token "${token}" 2>&1)"; then
echo "[server-provision] 使用 GENARRATIVE_SPACETIME_TOKEN 登录 SpacetimeDB CLI 失败。" >&2
printf "%s\\n" "${login_output}" | sed -E "s/[A-Za-z0-9_.=-]{24,}/[REDACTED]/g" >&2
@@ -536,6 +580,14 @@ render_api_env_example() {
deploy/env/api-server.env.example
}
render_external_generation_worker_env_example() {
cat deploy/env/external-generation-worker.env.example
}
render_external_generation_controller_env_example() {
cat deploy/env/external-generation-controller.env.example
}
render_otelcol_service() {
cat deploy/systemd/otelcol-contrib.service
}
@@ -755,6 +807,30 @@ render_api_service() {
deploy/systemd/genarrative-api.service
}
render_external_generation_worker_service() {
local current_escaped api_env_escaped worker_env_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
worker_env_escaped="$(escape_sed_replacement "${WORKER_ENV_FILE}")"
sed \
-e "s|/opt/genarrative/current|${current_escaped}|g" \
-e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \
-e "s|/etc/genarrative/external-generation-worker.env|${worker_env_escaped}|g" \
deploy/systemd/genarrative-external-generation-worker@.service
}
render_external_generation_controller_service() {
local current_escaped api_env_escaped controller_env_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
controller_env_escaped="$(escape_sed_replacement "${CONTROLLER_ENV_FILE}")"
sed \
-e "s|/opt/genarrative/current|${current_escaped}|g" \
-e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \
-e "s|/etc/genarrative/external-generation-controller.env|${controller_env_escaped}|g" \
deploy/systemd/genarrative-external-generation-controller.service
}
render_database_backup_service() {
local current_escaped env_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
@@ -775,6 +851,8 @@ render_health_patrol_service() {
require_path deploy/systemd/spacetimedb.service
require_path deploy/systemd/genarrative-api.service
require_path deploy/systemd/genarrative-external-generation-worker@.service
require_path deploy/systemd/genarrative-external-generation-controller.service
require_path deploy/systemd/genarrative-database-backup.service
require_path deploy/systemd/genarrative-database-backup.timer
require_path deploy/systemd/genarrative-health-patrol.service
@@ -790,6 +868,8 @@ require_path deploy/nginx/snippets/genarrative-pingora-canary.conf
require_path deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf
require_path deploy/logrotate/genarrative-pingora-gateway
require_path deploy/env/api-server.env.example
require_path deploy/env/external-generation-worker.env.example
require_path deploy/env/external-generation-controller.env.example
require_path scripts/deploy/maintenance-on.sh
require_path scripts/deploy/maintenance-off.sh
require_path scripts/deploy/maintenance-status.sh
@@ -834,14 +914,20 @@ sync_spacetime_install "${SPACETIME_ROOT}"
spacetimedb_service="$(mktemp)"
api_service="$(mktemp)"
external_generation_worker_service="$(mktemp)"
external_generation_controller_service="$(mktemp)"
database_backup_service="$(mktemp)"
health_patrol_service="$(mktemp)"
render_spacetimedb_service >"${spacetimedb_service}"
render_api_service >"${api_service}"
render_external_generation_worker_service >"${external_generation_worker_service}"
render_external_generation_controller_service >"${external_generation_controller_service}"
render_database_backup_service >"${database_backup_service}"
render_health_patrol_service >"${health_patrol_service}"
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
install_file "${external_generation_worker_service}" /etc/systemd/system/genarrative-external-generation-worker@.service 0644
install_file "${external_generation_controller_service}" /etc/systemd/system/genarrative-external-generation-controller.service 0644
install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644
install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644
install_file "${health_patrol_service}" /etc/systemd/system/genarrative-health-patrol.service 0644
@@ -849,7 +935,7 @@ install_file deploy/systemd/genarrative-health-patrol.timer /etc/systemd/system/
install_file deploy/systemd/genarrative-pingora-gateway.service /etc/systemd/system/genarrative-pingora-gateway.service 0644
install_file deploy/systemd/genarrative-pingora-gateway-direct-entry.conf /etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf 0644
install_file deploy/logrotate/genarrative-pingora-gateway /etc/logrotate.d/genarrative-pingora-gateway 0644
rm -f "${spacetimedb_service}" "${api_service}" "${database_backup_service}" "${health_patrol_service}"
rm -f "${spacetimedb_service}" "${api_service}" "${external_generation_worker_service}" "${external_generation_controller_service}" "${database_backup_service}" "${health_patrol_service}"
if [[ ! -f "${API_ENV_FILE}" ]]; then
echo "+ create ${API_ENV_FILE} from example"
@@ -863,8 +949,31 @@ else
fi
ensure_api_runtime_env_defaults
if [[ ! -f "${WORKER_ENV_FILE}" ]]; then
echo "+ create ${WORKER_ENV_FILE} from example"
if [[ "${DRY_RUN}" != "true" ]]; then
render_external_generation_worker_env_example >"${WORKER_ENV_FILE}"
chmod 0600 "${WORKER_ENV_FILE}"
chown root:root "${WORKER_ENV_FILE}"
fi
else
echo "[server-provision] 已存在 worker 环境文件,保留不覆盖: ${WORKER_ENV_FILE}"
fi
if [[ ! -f "${CONTROLLER_ENV_FILE}" ]]; then
echo "+ create ${CONTROLLER_ENV_FILE} from example"
if [[ "${DRY_RUN}" != "true" ]]; then
render_external_generation_controller_env_example >"${CONTROLLER_ENV_FILE}"
chmod 0600 "${CONTROLLER_ENV_FILE}"
chown root:root "${CONTROLLER_ENV_FILE}"
fi
else
echo "[server-provision] 已存在 controller 环境文件,保留不覆盖: ${CONTROLLER_ENV_FILE}"
fi
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
sync_otelcol_install
ensure_otelcol_runtime
otelcol_service="$(mktemp)"
render_otelcol_service >"${otelcol_service}"
install_file "${otelcol_service}" /etc/systemd/system/otelcol-contrib.service 0644
@@ -884,7 +993,9 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
run_cmd systemctl enable otelcol-contrib.service
fi
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-health-patrol.timer
stamp_database_backup_timer_now
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service genarrative-health-patrol.timer
run_cmd systemctl start genarrative-database-backup.timer
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
run_cmd systemctl restart otelcol-contrib.service
fi
@@ -893,8 +1004,12 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
ensure_spacetime_owner_client_token
if [[ -x "${CURRENT_LINK}/api-server" ]]; then
run_cmd systemctl restart genarrative-api.service
run_cmd systemctl enable --now genarrative-external-generation-worker@1.service
run_cmd systemctl restart genarrative-external-generation-worker@1.service
run_cmd systemctl enable --now genarrative-external-generation-controller.service
run_cmd systemctl restart genarrative-external-generation-controller.service
else
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server 首次启动。后续 API deploy 会重启服务"
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server、外部生成 worker 和 controller 首次启动。后续 API deploy 会启用并启动默认 worker 与 controller"
fi
fi
+62 -1
View File
@@ -20,14 +20,17 @@ const DEFAULT_PUBLIC_PATHS = [
const DEFAULT_SERVICES = [
'genarrative-api.service',
'genarrative-external-generation-controller.service',
'spacetimedb.service',
'nginx.service',
];
const PINGORA_DIRECT_SERVICES = [
'genarrative-api.service',
'genarrative-external-generation-controller.service',
'spacetimedb.service',
'genarrative-pingora-gateway.service',
];
const WORKER_SERVICE_PATTERN = 'genarrative-external-generation-worker@*.service';
const GATEWAY_MODES = new Set(['nginx', 'pingora-direct']);
function usage() {
@@ -287,6 +290,61 @@ async function checkService(serviceName, timeoutMs) {
);
}
async function checkActiveWorkerInstances(config) {
const result = await runCommand(
'systemctl',
[
'list-units',
WORKER_SERVICE_PATTERN,
'--type=service',
'--state=active',
'--no-legend',
'--plain',
'--no-pager',
],
config.timeoutMs,
);
if (result.code !== 0) {
return checkResult(
'service:external-generation-workers',
'CRITICAL',
'无法枚举外部生成 worker 实例',
{
command: result.command,
stderr: result.stderr.trim() || result.error,
},
);
}
const services = result.stdout
.split('\n')
.map((line) => line.trim().split(/\s+/u)[0])
.filter((service) =>
/^genarrative-external-generation-worker@.+\.service$/u.test(service),
);
if (services.length === 0) {
return checkResult(
'service:external-generation-workers',
'CRITICAL',
'没有 active 的外部生成 worker 实例',
{
command: result.command,
},
);
}
return checkResult(
'service:external-generation-workers',
'OK',
`${services.length} 个 worker active`,
{
command: result.command,
services,
},
);
}
function requestUrl(url, timeoutMs, headers = {}) {
return new Promise((resolve) => {
const startedAt = Date.now();
@@ -493,7 +551,9 @@ function gatewayServiceNames(config) {
}
async function checkRecentJournal(config) {
const serviceNames = gatewayServiceNames(config);
const serviceNames = [
...new Set([...gatewayServiceNames(config), WORKER_SERVICE_PATTERN]),
];
const args = [
...serviceNames.flatMap((serviceName) => ['-u', serviceName]),
'--since',
@@ -619,6 +679,7 @@ async function main() {
for (const serviceName of gatewayServiceNames(config)) {
checks.push(await checkService(serviceName, config.timeoutMs));
}
checks.push(await checkActiveWorkerInstances(config));
checks.push(
await checkHttp(
+1 -1
View File
@@ -9,7 +9,7 @@ OTELCOL_DOWNLOAD_ROOT="${OTELCOL_DOWNLOAD_ROOT:-https://github.com/open-telemetr
OTELCOL_ARCHIVE_PATH="${OTELCOL_ARCHIVE_PATH:-}"
OTELCOL_TARGET_BIN="${OTELCOL_TARGET_BIN:-/usr/local/bin/otelcol-contrib}"
SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}"
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.4.1}"
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}"
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}"
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}"
SPACETIME_EXPECTED_VERSION="${SPACETIME_EXPECTED_VERSION:-}"
+83
View File
@@ -0,0 +1,83 @@
# 本地 RAG
本目录提供项目文档的本地 RAG 索引脚本,主要供 Agent 在执行任务前后检索项目上下文使用。它不是新的人工阅读入口;开发者仍按 `AGENTS.md``docs/README.md``docs/project-memory/` 阅读项目文档。
项目默认不安装 RAG 运行时依赖,也不把 LanceDB、Transformers.js 或本地模型写入根 `package.json`
## 运行时依赖
RAG 运行时依赖安装在 gitignored 的 `.rag/runtime/`,模型缓存和向量库也都在 `.rag/` 下。
Agent 需要启用 RAG 检索时,应先询问用户是否安装本地依赖。用户确认后执行:
```bash
mkdir -p .rag/runtime
npm init -y --prefix .rag/runtime
npm install --prefix .rag/runtime @lancedb/lancedb@0.30.0 @huggingface/transformers@4.2.0
```
不要把这些依赖加入根 `package.json`
## 索引
首次运行会下载本地 embedding 模型到 `.rag/models/`。默认模型为 `Xenova/multilingual-e5-small`,适合中英文混合文档。
```bash
npm run rag:index
```
小样本 smoke
```bash
npm run rag:index -- --limit-files 3
```
只查看分片,不加载模型:
```bash
npm run rag:index -- --limit-files 3 --dry-run
```
## 搜索
默认输出 Agent 上下文包,包含来源、分数、候选上下文和使用规则:
```bash
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --limit 8
```
可限制上下文包大小:
```bash
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --limit 8 --max-chars 8000
```
可输出结构化格式,便于 Agent 或其它工具解析:
```bash
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format json
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format jsonl
```
如只想看短摘要,可使用旧式文本结果:
```bash
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format text
```
Agent 使用规则:
- 把 RAG 输出视为候选上下文,不直接当作最终事实。
- 需要精确改代码或文档时,仍要打开对应源文件核对。
- 来源冲突时,以当前代码和最新 `docs/` 为准。
## 索引范围
索引范围由 `scripts/rag/rag-config.json` 配置,默认包含:
- `AGENTS.md`
- `CONTEXT.md`,如果存在
- `docs/project-memory/`
- `docs/`
`.hermes/` 是 Hermes 工具目录,不作为项目知识库索引源。
+68
View File
@@ -0,0 +1,68 @@
import { mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
buildChunkId,
chunkText,
createEmbedder,
extractTitle,
hasFlag,
listSourceFiles,
loadRagRuntime,
parseLimitFiles,
readConfig,
repoRoot,
} from './rag-utils.mjs';
const config = readConfig();
const limitFiles = parseLimitFiles(process.argv);
const dryRun = hasFlag(process.argv, '--dry-run');
const files = listSourceFiles(config, limitFiles);
const rows = [];
for (const file of files) {
const text = readFileSync(file.path, 'utf8');
const title = extractTitle(text, file.rel);
for (const chunk of chunkText(text, config.chunk ?? {})) {
rows.push({
id: buildChunkId(file.rel, chunk.index),
path: file.rel,
title,
chunk_index: chunk.index,
source_weight: file.weight,
text: chunk.text,
});
}
}
console.log(`[rag:index] source files=${files.length}, chunks=${rows.length}`);
if (dryRun) {
for (const row of rows.slice(0, 10)) {
console.log(`- ${row.id} ${row.title}`);
}
process.exit(0);
}
if (rows.length === 0) {
throw new Error('No RAG chunks found.');
}
const { lancedb, transformers } = await loadRagRuntime(config);
const embed = await createEmbedder(transformers, config.model);
for (let index = 0; index < rows.length; index += 1) {
rows[index].vector = await embed(rows[index].text, 'passage');
if ((index + 1) % 25 === 0 || index + 1 === rows.length) {
console.log(`[rag:index] embedded ${index + 1}/${rows.length}`);
}
}
mkdirSync(join(repoRoot, config.databaseDir), { recursive: true });
const db = await lancedb.connect(join(repoRoot, config.databaseDir));
await db.createTable(config.tableName, rows, { mode: 'overwrite' });
console.log(
`[rag:index] wrote table=${config.tableName}, db=${config.databaseDir}, model=${config.model}`,
);
+46
View File
@@ -0,0 +1,46 @@
{
"runtimeDir": ".rag/runtime",
"databaseDir": ".rag/lancedb",
"modelCacheDir": ".rag/models",
"tableName": "project_docs",
"model": "Xenova/multilingual-e5-small",
"chunk": {
"maxChars": 1600,
"overlapChars": 220
},
"sources": [
{
"path": "AGENTS.md",
"weight": 1.4
},
{
"path": "CONTEXT.md",
"weight": 1.3,
"optional": true
},
{
"path": "docs/project-memory",
"weight": 1.35
},
{
"path": "docs",
"weight": 1.0
}
],
"exclude": [
".git/",
".rag/",
".hermes/",
".codegraph/",
".app/",
"node_modules/",
"dist/",
"build/",
"coverage/",
"logs/",
"output/",
"server-rs/target/",
"server-rs/target-",
"tmp/"
]
}
+221
View File
@@ -0,0 +1,221 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
export const configPath = join(repoRoot, 'scripts/rag/rag-config.json');
export function readConfig() {
return JSON.parse(readFileSync(configPath, 'utf8'));
}
export function normalizePath(filePath) {
return filePath.replace(/\\/gu, '/');
}
export function repoRelative(filePath) {
return normalizePath(relative(repoRoot, filePath));
}
export function resolveRepoPath(filePath) {
return resolve(repoRoot, filePath);
}
export function getRuntimeNodeModules(config) {
return join(repoRoot, config.runtimeDir, 'node_modules');
}
export function assertLocalRuntime(config) {
const runtimeModules = getRuntimeNodeModules(config);
const hasLance = existsSync(join(runtimeModules, '@lancedb/lancedb'));
const hasTransformers = existsSync(join(runtimeModules, '@huggingface/transformers'));
if (hasLance && hasTransformers) {
return runtimeModules;
}
throw new Error(
[
'本地 RAG 运行时依赖尚未安装。',
'按项目约定,RAG 依赖不进入根 package.json,也不默认安装。',
'需要启用 RAG 时,Agent 必须先询问用户,然后在本地 gitignored 目录安装:',
'',
` mkdir -p ${config.runtimeDir}`,
` npm init -y --prefix ${config.runtimeDir}`,
` npm install --prefix ${config.runtimeDir} @lancedb/lancedb@0.30.0 @huggingface/transformers@4.2.0`,
'',
`当前检查目录:${runtimeModules}`,
].join('\n'),
);
}
export async function loadRagRuntime(config) {
const runtimeModules = assertLocalRuntime(config);
const lancedb = await import(
pathToFileURL(join(runtimeModules, '@lancedb/lancedb/dist/index.js')).href
);
const transformers = await import(
pathToFileURL(
join(runtimeModules, '@huggingface/transformers/dist/transformers.node.mjs'),
).href
);
transformers.env.cacheDir = join(repoRoot, config.modelCacheDir);
transformers.env.useFSCache = true;
transformers.env.allowRemoteModels = true;
return { lancedb, transformers };
}
export function listSourceFiles(config, limitFiles = Number.POSITIVE_INFINITY) {
const excluded = config.exclude ?? [];
const files = [];
const seen = new Set();
for (const source of config.sources ?? []) {
const sourcePath = resolveRepoPath(source.path);
if (!existsSync(sourcePath)) {
if (!source.optional) {
throw new Error(`RAG source not found: ${source.path}`);
}
continue;
}
for (const filePath of walkTextFiles(sourcePath, excluded)) {
const rel = repoRelative(filePath);
if (seen.has(rel)) {
continue;
}
seen.add(rel);
files.push({ path: filePath, rel, weight: source.weight ?? 1 });
if (files.length >= limitFiles) {
return files;
}
}
}
return files;
}
function walkTextFiles(targetPath, excluded) {
const stat = statSync(targetPath);
if (stat.isFile()) {
return shouldReadFile(targetPath, excluded) ? [targetPath] : [];
}
const files = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
const child = join(dir, name);
const rel = `${repoRelative(child)}${statSync(child).isDirectory() ? '/' : ''}`;
if (excluded.some((prefix) => rel.startsWith(prefix))) {
continue;
}
const childStat = statSync(child);
if (childStat.isDirectory()) {
walk(child);
} else if (shouldReadFile(child, excluded)) {
files.push(child);
}
}
};
walk(targetPath);
return files.sort((a, b) => repoRelative(a).localeCompare(repoRelative(b)));
}
function shouldReadFile(filePath, excluded) {
const rel = repoRelative(filePath);
if (excluded.some((prefix) => rel.startsWith(prefix))) {
return false;
}
if (rel === 'AGENTS.md' || rel === 'CONTEXT.md' || rel.endsWith('/README.md')) {
return true;
}
return new Set(['.md', '.txt']).has(extname(filePath).toLowerCase());
}
export function chunkText(text, options) {
const maxChars = options.maxChars ?? 1600;
const overlapChars = options.overlapChars ?? 220;
const normalized = text.replace(/\r\n?/gu, '\n').trim();
if (!normalized) {
return [];
}
const blocks = normalized.split(/\n(?=#{1,6}\s+)/u);
const chunks = [];
let current = '';
const pushCurrent = () => {
const trimmed = current.trim();
if (trimmed) {
chunks.push(trimmed);
}
current = '';
};
for (const block of blocks) {
if ((current.length + block.length + 2) <= maxChars) {
current = current ? `${current}\n\n${block}` : block;
continue;
}
pushCurrent();
if (block.length <= maxChars) {
current = block;
continue;
}
for (let start = 0; start < block.length; start += Math.max(1, maxChars - overlapChars)) {
chunks.push(block.slice(start, start + maxChars).trim());
}
}
pushCurrent();
return chunks.map((chunk, index) => ({ index, text: chunk }));
}
export function buildChunkId(filePath, chunkIndex) {
return `${filePath}#${chunkIndex}`;
}
export function extractTitle(text, fallback) {
const title = text.match(/^#\s+(.+)$/mu)?.[1]?.trim();
return title || fallback;
}
export async function createEmbedder(transformers, model) {
const extractor = await transformers.pipeline('feature-extraction', model);
return async function embed(text, type) {
const prefix = type === 'query' ? 'query: ' : 'passage: ';
const output = await extractor(`${prefix}${text}`, {
pooling: 'mean',
normalize: true,
});
return Array.from(output.data, Number);
};
}
export function parseLimitFiles(argv) {
const value = readArg(argv, '--limit-files');
if (!value) {
return Number.POSITIVE_INFINITY;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid --limit-files value: ${value}`);
}
return parsed;
}
export function readArg(argv, name, fallback = undefined) {
const index = argv.indexOf(name);
if (index === -1) {
return fallback;
}
return argv[index + 1] ?? fallback;
}
export function hasFlag(argv, name) {
return argv.includes(name);
}
+195
View File
@@ -0,0 +1,195 @@
import { join } from 'node:path';
import {
createEmbedder,
hasFlag,
loadRagRuntime,
readArg,
readConfig,
repoRoot,
} from './rag-utils.mjs';
const config = readConfig();
const query = readArg(process.argv, '--query') ?? process.argv.slice(2).join(' ');
const limit = Number(readArg(process.argv, '--limit', '8'));
const maxChars = Number(readArg(process.argv, '--max-chars', '12000'));
const format = readArg(process.argv, '--format', 'context');
const includeText = !hasFlag(process.argv, '--no-text');
if (!query) {
throw new Error(
'Usage: node scripts/rag/search-docs.mjs --query "搜索内容" [--limit 8] [--format context|json|jsonl|text] [--max-chars 12000]',
);
}
if (!['context', 'json', 'jsonl', 'text'].includes(format)) {
throw new Error(`Unsupported --format value: ${format}`);
}
if (!Number.isFinite(limit) || limit <= 0 || !Number.isInteger(limit)) {
throw new Error(`Invalid --limit value: ${limit}`);
}
if (!Number.isFinite(maxChars) || maxChars <= 0 || !Number.isInteger(maxChars)) {
throw new Error(`Invalid --max-chars value: ${maxChars}`);
}
const { lancedb, transformers } = await loadRagRuntime(config);
const embed = await createEmbedder(transformers, config.model);
const queryVector = await embed(query, 'query');
const db = await lancedb.connect(join(repoRoot, config.databaseDir));
const table = await db.openTable(config.tableName);
const rawResults = await table
.vectorSearch(queryVector)
.select(['id', 'path', 'title', 'chunk_index', 'source_weight', 'text', '_distance'])
.limit(Math.max(limit * 3, limit))
.toArray();
const results = rawResults
.map((row) => ({
...row,
score: (1 / (1 + Number(row._distance ?? 0))) * Number(row.source_weight ?? 1),
}))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
const payload = buildAgentPayload(query, results, {
model: config.model,
tableName: config.tableName,
maxChars,
includeText,
});
if (format === 'json') {
console.log(JSON.stringify(payload, null, 2));
} else if (format === 'jsonl') {
for (const result of payload.results) {
console.log(JSON.stringify(result));
}
} else if (format === 'text') {
printTextResults(payload.results);
} else {
console.log(formatContextPack(payload));
}
function buildAgentPayload(searchQuery, rows, options) {
const outputRows = [];
let remainingChars = options.maxChars;
for (const [index, row] of rows.entries()) {
const source = `${row.path}#${row.chunk_index}`;
const text = String(row.text ?? '').trim();
const result = {
rank: index + 1,
id: row.id,
source,
path: row.path,
title: row.title,
chunkIndex: Number(row.chunk_index),
score: Number(row.score),
distance: Number(row._distance ?? 0),
sourceWeight: Number(row.source_weight ?? 1),
};
if (options.includeText) {
const capped = capText(text, Math.max(0, remainingChars));
result.text = capped.text;
result.truncated = capped.truncated;
remainingChars -= result.text.length;
}
outputRows.push(result);
}
return {
kind: 'genarrative-rag-context',
query: searchQuery,
generatedAt: new Date().toISOString(),
model: options.model,
table: options.tableName,
maxChars: options.maxChars,
remainingChars,
resultCount: outputRows.length,
usage: [
'This context pack is primarily for Agent consumption.',
'Use sources as candidate context and inspect authoritative files before editing when exact line-level changes matter.',
'Prefer docs/project-memory and current docs over stale historical notes when sources conflict.',
],
results: outputRows,
};
}
function capText(text, budget) {
if (budget <= 0) {
return { text: '', truncated: text.length > 0 };
}
if (text.length <= budget) {
return { text, truncated: false };
}
return { text: `${text.slice(0, Math.max(0, budget - 18)).trimEnd()}\n[TRUNCATED]`, truncated: true };
}
function formatContextPack(payload) {
const lines = [
'# Genarrative RAG Context',
'',
`query: ${payload.query}`,
`model: ${payload.model}`,
`results: ${payload.resultCount}`,
`maxChars: ${payload.maxChars}`,
'',
'## Agent Usage',
'',
'- This context pack is primarily for Agent consumption.',
'- Treat sources as candidate context; inspect authoritative files before exact edits.',
'- If sources conflict, prefer current code and current docs over stale historical notes.',
'',
'## Sources',
'',
];
for (const result of payload.results) {
lines.push(
`${result.rank}. ${result.source} score=${result.score.toFixed(4)} distance=${result.distance.toFixed(4)} title=${result.title}`,
);
}
lines.push('', '## Context', '');
for (const result of payload.results) {
const fence = buildMarkdownFence(result.text ?? '');
lines.push(
`### [${result.rank}] ${result.title}`,
'',
`source: ${result.source}`,
`score: ${result.score.toFixed(4)}`,
'',
`${fence}text`,
result.text ?? '',
fence,
'',
);
}
return lines.join('\n');
}
function buildMarkdownFence(text) {
const longest = Math.max(3, ...Array.from(text.matchAll(/`+/gu), (match) => match[0].length));
return '`'.repeat(longest + 1);
}
function printTextResults(rows) {
for (const result of rows) {
const preview = String(result.text ?? '').replace(/\s+/gu, ' ').slice(0, 260);
console.log(
[
`${result.rank}. ${result.source}`,
` title: ${result.title}`,
` score: ${result.score.toFixed(4)} distance: ${result.distance.toFixed(4)}`,
` ${preview}`,
].join('\n'),
);
}
}