合并 master 到 Pingora 网关分支
保留 Pingora 网关发布与巡检能力 合入外部生成 worker 与 controller 运维链路 对齐 SpacetimeDB 2.5.0 依赖锁定 更新项目共享记忆与生产运维文档
This commit is contained in:
@@ -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。',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 反序列化失败');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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 工具目录,不作为项目知识库索引源。
|
||||
@@ -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}`,
|
||||
);
|
||||
@@ -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/"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user