feat: prepare deep search production runtime
Memind CI / Test, build, and release guards (pull_request) Successful in 4m4s
Memind CI / Test, build, and release guards (pull_request) Successful in 4m4s
This commit is contained in:
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user