feat: prepare deep search production runtime
Memind CI / Test, build, and release guards (pull_request) Successful in 4m4s

This commit is contained in:
john
2026-07-24 09:53:06 +08:00
parent 46041c3456
commit 53ed38c250
16 changed files with 681 additions and 9 deletions
+75
View File
@@ -0,0 +1,75 @@
import { spawn } from 'node:child_process';
import {
chmod,
copyFile,
mkdir,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const runtimeRoot = path.join(root, '.runtime', 'deep-search');
const esbuildBin = path.join(root, 'node_modules', '.bin', 'esbuild');
const runtimeNodeTarget = process.env.DEEP_SEARCH_RUNTIME_NODE_TARGET || 'node24';
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: root,
stdio: 'inherit',
env: process.env,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) return resolve();
reject(new Error(`${command} exited with ${code ?? signal}`));
});
});
}
await rm(runtimeRoot, { recursive: true, force: true });
await mkdir(path.join(runtimeRoot, 'scripts'), { recursive: true });
await run(esbuildBin, [
'deep-search-server.mjs',
'--bundle',
'--platform=node',
'--format=esm',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/deep-search/deep-search-server.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
]);
for (const script of [
'run-deep-search-prod.sh',
'install-deep-search-runtime-prod.sh',
]) {
const source = path.join(root, 'scripts', script);
const target = path.join(runtimeRoot, 'scripts', script);
await copyFile(source, target);
await chmod(target, 0o755);
}
await writeFile(
path.join(runtimeRoot, 'RUNBOOK.txt'),
[
'TKMind Deep Search standalone runtime',
'',
'Production base: /Users/john/Project/deep-search-runtime',
'Persistent env: shared/.env',
'Persistent data: data/research.sqlite',
'Release code: releases/<release-id>/',
'Active release: current -> releases/<release-id>/',
'LaunchAgent: cn.tkmind.memind-deep-search',
'Health: http://127.0.0.1:20100/health',
'SearXNG: http://127.0.0.1:20080/search',
'',
'The GitHub token stays in shared/.env and is never passed to goosed MCP processes.',
'Portal and memindadm releases are intentionally separate.',
'',
].join('\n'),
);
console.log(`Deep Search runtime built at ${runtimeRoot}`);
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relativePath) => readFileSync(path.join(root, relativePath), 'utf8');
test('Deep Search production runtime is isolated from Portal releases', () => {
const builder = read('scripts/build-deep-search-runtime.mjs');
assert.match(builder, /deep-search-server\.mjs/);
assert.match(builder, /\.runtime', 'deep-search/);
const runner = read('scripts/run-deep-search-prod.sh');
assert.match(runner, /shared\/\.env/);
assert.match(runner, /data\/research\.sqlite/);
assert.match(runner, /127\.0\.0\.1:20080\/search/);
const installer = read('scripts/install-deep-search-runtime-prod.sh');
assert.match(installer, /cn\.tkmind\.memind-deep-search/);
assert.match(installer, /chmod 600/);
assert.match(installer, /openssl rand -hex 32/);
const release = read('scripts/release-deep-search-runtime-prod.sh');
assert.match(release, /branch.*main/);
assert.match(release, /CONFIRMED_CI_SHA/);
assert.match(release, /shasum -a 256/);
assert.match(release, /rollback/);
assert.doesNotMatch(release, /release-portal-runtime-prod/);
});
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Runs on 103 from a verified Deep Search release directory.
set -euo pipefail
RUNTIME_BASE="${DEEP_SEARCH_RUNTIME_BASE:-/Users/john/Project/deep-search-runtime}"
RELEASE_DIR="${DEEP_SEARCH_RELEASE_DIR:?DEEP_SEARCH_RELEASE_DIR is required}"
ENV_DIR="${RUNTIME_BASE}/shared"
ENV_FILE="${ENV_DIR}/.env"
DATA_DIR="${RUNTIME_BASE}/data"
LOG_DIR="${RUNTIME_BASE}/logs"
PLIST="${HOME}/Library/LaunchAgents/cn.tkmind.memind-deep-search.plist"
LABEL="cn.tkmind.memind-deep-search"
GUI="gui/$(id -u)"
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
[[ -x "${NODE_BIN}" ]] || {
echo "missing Node.js runtime: ${NODE_BIN}" >&2
exit 1
}
[[ -f "${RELEASE_DIR}/deep-search-server.mjs" ]] || {
echo "missing Deep Search server in ${RELEASE_DIR}" >&2
exit 1
}
mkdir -p "${ENV_DIR}" "${DATA_DIR}" "${LOG_DIR}" "$(dirname "${PLIST}")"
touch "${ENV_FILE}"
chmod 600 "${ENV_FILE}"
ensure_env() {
local key="$1"
local value="$2"
if ! grep -qE "^${key}=" "${ENV_FILE}"; then
printf '%s=%s\n' "${key}" "${value}" >> "${ENV_FILE}"
fi
}
if ! grep -qE '^TKMIND_DEEP_SEARCH_SECRET=.+$' "${ENV_FILE}"; then
secret="$(openssl rand -hex 32)"
ensure_env TKMIND_DEEP_SEARCH_SECRET "${secret}"
fi
ensure_env TKMIND_DEEP_SEARCH_HOST "127.0.0.1"
ensure_env TKMIND_DEEP_SEARCH_PORT "20100"
ensure_env TKMIND_DEEP_SEARCH_DB "${DATA_DIR}/research.sqlite"
ensure_env TKMIND_DEEP_SEARCH_SEARXNG_URL "http://127.0.0.1:20080/search"
ensure_env TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL "http://127.0.0.1:8081/api/internal/deep-search/llm"
ensure_env TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET '${TKMIND_DEEP_SEARCH_SECRET}'
ln -sfn "${RELEASE_DIR}" "${RUNTIME_BASE}/current"
cat > "${PLIST}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>${LABEL}</string>
<key>ProgramArguments</key><array><string>${RUNTIME_BASE}/current/scripts/run-deep-search-prod.sh</string></array>
<key>WorkingDirectory</key><string>${RUNTIME_BASE}/current</string>
<key>EnvironmentVariables</key><dict>
<key>DEEP_SEARCH_RUNTIME_BASE</key><string>${RUNTIME_BASE}</string>
<key>NODE_BIN</key><string>${NODE_BIN}</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Background</string>
<key>ThrottleInterval</key><integer>10</integer>
<key>StandardOutPath</key><string>${LOG_DIR}/deep-search.log</string>
<key>StandardErrorPath</key><string>${LOG_DIR}/deep-search.log</string>
</dict></plist>
EOF
plutil -lint "${PLIST}" >/dev/null
launchctl bootout "${GUI}/${LABEL}" >/dev/null 2>&1 || true
launchctl bootstrap "${GUI}" "${PLIST}"
launchctl enable "${GUI}/${LABEL}"
launchctl kickstart -k "${GUI}/${LABEL}"
for _ in $(seq 1 30); do
if curl -fsS --max-time 2 "http://127.0.0.1:20100/health" >/dev/null; then
exit 0
fi
sleep 1
done
echo "Deep Search health check timed out" >&2
exit 1
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env bash
# Publishes only the independently versioned Deep Search runtime to 103.
# Portal and memindadm are not modified by this workflow.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-58.38.22.103}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
RUNTIME_BASE="${DEEP_SEARCH_RUNTIME_BASE:-${REMOTE_ROOT}/deep-search-runtime}"
INCOMING="${REMOTE_ROOT}/incoming/deep-search-runtime"
RELEASE_ID="$(date +%Y%m%d-%H%M%S)-$(git -C "${ROOT}" rev-parse --short HEAD)"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/deep-search-runtime-release.XXXXXX")"
DRY_RUN=0
AUTO_YES=0
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--yes) AUTO_YES=1 ;;
-h|--help)
cat <<'EOF'
Usage: bash scripts/release-deep-search-runtime-prod.sh [--dry-run] [--yes]
Required production gates:
- current branch is main
- HEAD exactly matches origin/main
- worktree is clean
- CONFIRMED_CI_SHA equals the full current HEAD
This installs only Deep Search on 103. It does not release Portal or memindadm.
EOF
exit 0
;;
*)
echo "unknown argument: $1" >&2
exit 1
;;
esac
shift
done
git -C "${ROOT}" fetch origin main --quiet
branch="$(git -C "${ROOT}" branch --show-current)"
head_sha="$(git -C "${ROOT}" rev-parse HEAD)"
origin_sha="$(git -C "${ROOT}" rev-parse origin/main)"
[[ "${branch}" == "main" ]] || {
echo "Deep Search production release must run from main" >&2
exit 1
}
[[ "${head_sha}" == "${origin_sha}" ]] || {
echo "main must exactly match origin/main" >&2
exit 1
}
[[ -z "$(git -C "${ROOT}" status --porcelain=v1 --untracked-files=all)" ]] || {
echo "worktree must be clean" >&2
exit 1
}
[[ "${CONFIRMED_CI_SHA:-}" == "${head_sha}" ]] || {
echo "CONFIRMED_CI_SHA must equal the verified main HEAD: ${head_sha}" >&2
exit 1
}
ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh"
(
cd "${ROOT}"
npm run test:deep-search
node --test capabilities.test.mjs scripts/deep-search-runtime-package.test.mjs
npm run build:deep-search-runtime
)
RUNTIME_DIR="${ROOT}/.runtime/deep-search"
for file in \
deep-search-server.mjs \
scripts/run-deep-search-prod.sh \
scripts/install-deep-search-runtime-prod.sh \
RUNBOOK.txt; do
[[ -f "${RUNTIME_DIR}/${file}" ]] || {
echo "missing runtime artifact: ${file}" >&2
exit 1
}
done
MANIFEST="${RUNTIME_DIR}/release-manifest.txt"
{
echo "release_id=${RELEASE_ID}"
echo "git_head=${head_sha}"
echo "git_branch=${branch}"
echo "created_at=$(date '+%Y-%m-%d %H:%M:%S %z')"
echo "artifact=.runtime/deep-search"
echo "persistent_env=${RUNTIME_BASE}/shared/.env"
echo "persistent_db=${RUNTIME_BASE}/data/research.sqlite"
} > "${MANIFEST}"
ARCHIVE="${TMP_DIR}/deep-search-runtime-${RELEASE_ID}.tar.gz"
SHA_FILE="${ARCHIVE}.sha256"
tar -C "${RUNTIME_DIR}" -czf "${ARCHIVE}" .
(cd "${TMP_DIR}" && shasum -a 256 "$(basename "${ARCHIVE}")" > "$(basename "${SHA_FILE}")")
if [[ "${DRY_RUN}" -eq 1 ]]; then
shasum -a 256 -c "${SHA_FILE}"
echo "Deep Search dry-run artifact verified: ${ARCHIVE}"
exit 0
fi
if [[ "${AUTO_YES}" -ne 1 ]]; then
echo "Target: ${HOST}:${RUNTIME_BASE}"
echo "Release: ${RELEASE_ID}"
read -r -p "Install standalone Deep Search on 103? [y/N] " confirm </dev/tty
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
fi
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "mkdir -p '${INCOMING}' '${RUNTIME_BASE}/releases' '${RUNTIME_BASE}/backups'"
scp -q "${ARCHIVE}" "${SHA_FILE}" "${HOST}:${INCOMING}/"
ssh -o BatchMode=yes "${HOST}" \
"RELEASE_ID='${RELEASE_ID}' RUNTIME_BASE='${RUNTIME_BASE}' INCOMING='${INCOMING}' /bin/bash" <<'REMOTE'
set -euo pipefail
archive="${INCOMING}/deep-search-runtime-${RELEASE_ID}.tar.gz"
sha_file="${archive}.sha256"
release_dir="${RUNTIME_BASE}/releases/${RELEASE_ID}"
backup_dir="${RUNTIME_BASE}/backups/${RELEASE_ID}"
current_link="${RUNTIME_BASE}/current"
plist="${HOME}/Library/LaunchAgents/cn.tkmind.memind-deep-search.plist"
label="cn.tkmind.memind-deep-search"
gui="gui/$(id -u)"
previous_target="$(readlink "${current_link}" 2>/dev/null || true)"
database_path="${RUNTIME_BASE}/data/research.sqlite"
mkdir -p "${release_dir}" "${backup_dir}"
cp "${plist}" "${backup_dir}/launchagent.plist" 2>/dev/null || true
if [[ -f "${database_path}" ]]; then
sqlite3 "${database_path}" ".timeout 10000" \
".backup '${backup_dir}/research.sqlite'" >/dev/null
fi
cd "${INCOMING}"
shasum -a 256 -c "$(basename "${sha_file}")"
tar -xzf "${archive}" -C "${release_dir}"
rollback() {
if [[ -n "${previous_target}" && -d "${previous_target}" ]]; then
ln -sfn "${previous_target}" "${current_link}"
else
rm -f "${current_link}"
fi
if [[ -f "${backup_dir}/launchagent.plist" ]]; then
cp "${backup_dir}/launchagent.plist" "${plist}"
launchctl bootout "${gui}/${label}" >/dev/null 2>&1 || true
launchctl bootstrap "${gui}" "${plist}" >/dev/null 2>&1 || true
launchctl kickstart -k "${gui}/${label}" >/dev/null 2>&1 || true
fi
}
trap rollback ERR
DEEP_SEARCH_RUNTIME_BASE="${RUNTIME_BASE}" \
DEEP_SEARCH_RELEASE_DIR="${release_dir}" \
bash "${release_dir}/scripts/install-deep-search-runtime-prod.sh"
set -a
# shellcheck disable=SC1090
source "${RUNTIME_BASE}/shared/.env"
set +a
curl -fsS --max-time 5 "http://127.0.0.1:20100/health" >/dev/null
curl -fsS --max-time 20 \
-H "X-Secret-Key: ${TKMIND_DEEP_SEARCH_SECRET}" \
-H "Content-Type: application/json" \
-d '{"query":"OpenAI","limit":3}' \
"http://127.0.0.1:20100/v1/search" \
| grep -q '"results"'
docker_bin=""
for candidate in /usr/local/bin/docker /opt/homebrew/bin/docker; do
if [[ -x "${candidate}" ]]; then
docker_bin="${candidate}"
break
fi
done
if [[ -n "${docker_bin}" ]]; then
goosed_container="$("${docker_bin}" ps --format '{{.Names}}' | grep -E '^goosed-prod-' | head -1)"
if [[ -n "${goosed_container}" ]]; then
"${docker_bin}" exec "${goosed_container}" sh -lc \
'curl -fsS --max-time 5 http://host.docker.internal:20100/health >/dev/null'
fi
fi
trap - ERR
printf '%s\n' "${RELEASE_ID}" > "${RUNTIME_BASE}/current-release.txt"
REMOTE
echo "Deep Search installed and verified on 103: ${RELEASE_ID}"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
RUNTIME_BASE="${DEEP_SEARCH_RUNTIME_BASE:-/Users/john/Project/deep-search-runtime}"
ENV_FILE="${DEEP_SEARCH_ENV_FILE:-${RUNTIME_BASE}/shared/.env}"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "[deep-search-start] missing environment file: ${ENV_FILE}" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "${ENV_FILE}"
set +a
: "${TKMIND_DEEP_SEARCH_SECRET:?missing TKMIND_DEEP_SEARCH_SECRET}"
export NODE_ENV=production
export TKMIND_DEEP_SEARCH_HOST="${TKMIND_DEEP_SEARCH_HOST:-127.0.0.1}"
export TKMIND_DEEP_SEARCH_PORT="${TKMIND_DEEP_SEARCH_PORT:-20100}"
export TKMIND_DEEP_SEARCH_DB="${TKMIND_DEEP_SEARCH_DB:-${RUNTIME_BASE}/data/research.sqlite}"
export TKMIND_DEEP_SEARCH_SEARXNG_URL="${TKMIND_DEEP_SEARCH_SEARXNG_URL:-http://127.0.0.1:20080/search}"
export TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL="${TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL:-http://127.0.0.1:8081/api/internal/deep-search/llm}"
export TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET="${TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET:-${TKMIND_DEEP_SEARCH_SECRET}}"
mkdir -p "$(dirname "${TKMIND_DEEP_SEARCH_DB}")" "${RUNTIME_BASE}/logs"
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
if [[ ! -x "${NODE_BIN}" ]]; then
NODE_BIN="$(command -v node)"
fi
exec "${NODE_BIN}" "${RUNTIME_ROOT}/deep-search-server.mjs"