Compare commits

..

13 Commits

Author SHA1 Message Date
Jesse_Chen 7b620c7a2e fix: repair production lock through direct mount
Independent Staging Quality Gate / validate (push) Successful in 13m1s
Independent Staging Quality Gate / publish (push) Successful in 2m13s
2026-08-16 04:16:12 +08:00
Jesse_Chen 9f31de7b4a fix: preserve recovery helper traversal
Independent Staging Quality Gate / validate (push) Successful in 12m19s
Independent Staging Quality Gate / publish (push) Successful in 2m14s
2026-08-16 03:30:03 +08:00
Jesse_Chen 29a7295667 fix: reuse docker boundary for production recovery
Independent Staging Quality Gate / validate (push) Successful in 13m32s
Independent Staging Quality Gate / publish (push) Successful in 2m13s
2026-08-16 02:42:18 +08:00
Jesse_Chen 2b1deff0e9 fix: repair production recovery lock ownership
Independent Staging Quality Gate / validate (push) Successful in 12m23s
Independent Staging Quality Gate / publish (push) Successful in 2m6s
2026-08-16 01:50:50 +08:00
Jesse_Chen 934c4175d3 ops: automate production recovery attestation
Independent Staging Quality Gate / validate (push) Successful in 13m3s
Independent Staging Quality Gate / publish (push) Successful in 2m18s
2026-08-16 01:03:10 +08:00
Jesse_Chen 0d59d51814 fix: allow public daily guidance without birth minute
Independent Staging Quality Gate / validate (push) Successful in 14m4s
Independent Staging Quality Gate / publish (push) Successful in 9m22s
2026-08-15 23:55:57 +08:00
Jesse_Chen b6df2d9e82 test: accept stable profile refresh semantics
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
2026-08-15 23:45:34 +08:00
Jesse_Chen 1084dce770 fix: avoid repeated daily starlanguage refresh
Independent Staging Quality Gate / validate (push) Failing after 11m57s
Independent Staging Quality Gate / publish (push) Has been skipped
2026-08-15 23:28:49 +08:00
Jesse_Chen cf945b3455 test: accept user-centered daily draft
Independent Staging Quality Gate / validate (push) Successful in 12m21s
Independent Staging Quality Gate / publish (push) Successful in 9m12s
2026-08-15 22:07:43 +08:00
Jesse_Chen 12b6398c7b fix: make starter questions user-centered
Independent Staging Quality Gate / validate (push) Failing after 12m39s
Independent Staging Quality Gate / publish (push) Has been skipped
2026-08-15 21:52:02 +08:00
Jesse_Chen 47a22319fd fix(rectification): resolve missing birth timezone offsets
Independent Staging Quality Gate / validate (push) Successful in 12m36s
Independent Staging Quality Gate / publish (push) Successful in 9m33s
2026-08-15 21:01:00 +08:00
Jesse_Chen 5b023e9480 fix(rectification): open cases for uncertain birth times
Independent Staging Quality Gate / validate (push) Successful in 12m32s
Independent Staging Quality Gate / publish (push) Successful in 9m9s
2026-08-15 20:24:43 +08:00
Jesse_Chen 995da2d4b4 fix(onboarding): restore uncertain birth-time intake
Independent Staging Quality Gate / validate (push) Successful in 20m10s
Independent Staging Quality Gate / publish (push) Successful in 8m46s
2026-08-15 19:29:33 +08:00
30 changed files with 1350 additions and 119 deletions
@@ -7,6 +7,7 @@ on:
- '.gitea/workflows/deploy-staging.yml'
- '.gitea/workflows/migrate-staging-database.yml'
- '.gitea/workflows/migrate-production-database.yml'
- '.gitea/workflows/create-production-recovery.yml'
- 'deploy/**'
- 'frontend/**'
- 'jyotish_vedic/**'
@@ -0,0 +1,213 @@
name: Create Production Recovery Point (manual only)
on:
workflow_dispatch:
inputs:
deploy_sha:
description: Exact accepted production release SHA this recovery point protects
required: true
type: string
permissions:
contents: read
actions: write
concurrency:
group: production-mutation
cancel-in-progress: false
queue: max
jobs:
recover:
runs-on: manman-linux
timeout-minutes: 30
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_HOST: ${{ vars.PRODUCTION_HOST }}
DEPLOY_PORT: ${{ vars.PRODUCTION_PORT }}
DEPLOY_USER: ${{ vars.PRODUCTION_USER }}
DEPLOY_PATH: ${{ vars.PRODUCTION_PATH }}
STAGING_URL: ${{ vars.STAGING_URL }}
PRODUCTION_KNOWN_HOSTS: ${{ vars.PRODUCTION_KNOWN_HOSTS }}
steps:
- name: Validate exact accepted release
id: revision
env:
DEPLOY_SHA: ${{ inputs.deploy_sha }}
run: |
set -euo pipefail
[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; }
[[ "$GITEA_SHA" == "$DEPLOY_SHA" ]] || { echo "dispatch recovery from the exact main release SHA" >&2; exit 1; }
[[ "$STAGING_URL" == "https://staging.jyotisha.chat" ]] || { echo "unexpected staging acceptance URL" >&2; exit 1; }
read_ref_sha() {
local branch="$1"
curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" |
jq -er --arg ref "refs/heads/$branch" '
select(type == "array" and length == 1) |
.[0] | select(.ref == $ref) | .object.sha |
select(test("^[0-9a-f]{40}$"))
'
}
staging_head="$(read_ref_sha staging)"
main_head="$(read_ref_sha main)"
[[ "$main_head" == "$DEPLOY_SHA" && "$staging_head" == "$DEPLOY_SHA" ]] || {
echo "production recovery requires main and staging to equal deploy_sha" >&2
exit 1
}
observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \
"$STAGING_URL/api/health" | jq -er '.deployment.gitCommit | select(test("^[0-9a-f]{40}$"))')"
[[ "$observed_staging_sha" == "$DEPLOY_SHA" ]] || {
echo "public staging has not accepted the requested SHA" >&2
exit 1
}
release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&event=workflow_dispatch&status=success&limit=100")"
jq -e --arg sha "$DEPLOY_SHA" '
any(.workflow_runs[]?;
(.path | split("@")[0] | endswith("release-quality-gate.yml")) and
.head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success"
)
' <<<"$release_runs" >/dev/null || {
echo "no successful exact-SHA manual release quality gate found" >&2
exit 1
}
echo "sha=$DEPLOY_SHA" >>"$GITHUB_OUTPUT"
- name: Checkout exact recovery controller
env:
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
git init .
git remote remove origin 2>/dev/null || true
git remote add origin https://git.copse.top/root/Jyotisha.git
git fetch --no-tags origin "$DEPLOY_SHA"
git checkout --detach --force "$DEPLOY_SHA"
[[ "$(git rev-parse HEAD)" == "$DEPLOY_SHA" ]]
- name: Create, restore-verify, and retrieve encrypted recovery point
id: recovery
env:
SSH_PRIVATE_KEY_BASE64: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
DEPLOY_SHA: ${{ steps.revision.outputs.sha }}
run: |
set -euo pipefail
set +x
[[ "$DEPLOY_HOST" == "118.194.235.34" ]]
[[ "$DEPLOY_PORT" =~ ^[1-9][0-9]{0,4}$ ]] && (( DEPLOY_PORT <= 65535 ))
[[ "$DEPLOY_USER" == "deploy" ]]
[[ "$DEPLOY_PATH" == "/opt/jyotisha-production" ]]
[[ "${GITHUB_RUN_ID:-}" =~ ^[0-9]+$ ]]
test -n "$PRODUCTION_KNOWN_HOSTS"
ssh_root="${RUNNER_TEMP}/production-recovery-ssh"
key_path="$ssh_root/id_ed25519"
known_hosts_path="$ssh_root/known_hosts"
artifact_directory="artifacts/production-recovery"
install -m 700 -d "$ssh_root" "$artifact_directory"
test -n "$SSH_PRIVATE_KEY_BASE64"
printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >"$key_path"
printf '%s\n' "$PRODUCTION_KNOWN_HOSTS" | tr -d '\r' >"$known_hosts_path"
chmod 600 "$key_path" "$known_hosts_path"
ssh-keygen -y -f "$key_path" >/dev/null
ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path")
scp_options=(-i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path")
remote="$DEPLOY_USER@$DEPLOY_HOST"
incoming="$(ssh "${ssh_options[@]}" "$remote" 'mktemp -d /tmp/jyotisha-production-recovery.XXXXXXXXXX')"
[[ "$incoming" == /tmp/jyotisha-production-recovery.* ]]
cleanup() {
ssh "${ssh_options[@]}" "$remote" "rm -rf -- '$incoming'" >/dev/null 2>&1 || true
rm -rf -- "$ssh_root"
}
trap cleanup EXIT
git show "$DEPLOY_SHA:deploy/run-production-recovery.sh" >"$ssh_root/run-production-recovery.sh"
chmod 700 "$ssh_root/run-production-recovery.sh"
scp "${scp_options[@]}" "$ssh_root/run-production-recovery.sh" "$remote:$incoming/run-production-recovery.sh"
ssh "${ssh_options[@]}" "$remote" \
"DEPLOY_PATH='$DEPLOY_PATH' RECOVERY_RUN_ID='$GITHUB_RUN_ID' bash '$incoming/run-production-recovery.sh'" \
>"$ssh_root/recovery-output.env"
[[ "$(wc -l <"$ssh_root/recovery-output.env" | tr -d ' ')" == 7 ]]
recovery_reference="$(awk -F= '$1 == "RECOVERY_REFERENCE" {print $2}' "$ssh_root/recovery-output.env")"
recovery_created_at="$(awk -F= '$1 == "RECOVERY_CREATED_AT" {print $2}' "$ssh_root/recovery-output.env")"
recovery_verified_at="$(awk -F= '$1 == "RECOVERY_VERIFIED_AT" {print $2}' "$ssh_root/recovery-output.env")"
recovery_sha256="$(awk -F= '$1 == "RECOVERY_SHA256" {print $2}' "$ssh_root/recovery-output.env")"
restore_verified="$(awk -F= '$1 == "RESTORE_VERIFIED" {print $2}' "$ssh_root/recovery-output.env")"
backup_basename="$(awk -F= '$1 == "BACKUP_BASENAME" {print $2}' "$ssh_root/recovery-output.env")"
verify_basename="$(awk -F= '$1 == "VERIFY_BASENAME" {print $2}' "$ssh_root/recovery-output.env")"
[[ "$backup_basename" =~ ^production-pre-migration-[0-9]{8}T[0-9]{6}Z\.dump\.enc$ ]]
[[ "$verify_basename" == "${backup_basename%.dump.enc}-restore-verify.json" ]]
[[ "$recovery_reference" == "gitea-actions-run-${GITHUB_RUN_ID}/${backup_basename}" ]]
[[ "$recovery_created_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
[[ "$recovery_verified_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
[[ "$recovery_sha256" =~ ^[0-9a-f]{64}$ ]]
[[ "$restore_verified" == "true" ]]
scp "${scp_options[@]}" \
"$remote:$DEPLOY_PATH/backups/$backup_basename" \
"$remote:$DEPLOY_PATH/backups/$verify_basename" \
"$artifact_directory/"
test -s "$artifact_directory/$backup_basename"
test -s "$artifact_directory/$verify_basename"
printf '%s %s\n' "$recovery_sha256" "$artifact_directory/$backup_basename" | sha256sum --check --status
jq -e \
--arg backup "$backup_basename" \
--arg sha "$recovery_sha256" \
--arg created "$recovery_created_at" \
--arg verified "$recovery_verified_at" '
.mode == "restore_verify" and .ok == true and
.backup == $backup and .sha256 == $sha and
.created_at == $created and .verified_at == $verified and
.restore_database_removed == true and
([.checks.identity_users, .checks.profiles, .checks.credit_transactions, .checks.public_tables] |
all(type == "number" and . >= 0 and floor == .))
' "$artifact_directory/$verify_basename" >/dev/null
printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_VERIFIED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nBACKUP_BASENAME=%s\nVERIFY_BASENAME=%s\n' \
"$recovery_reference" "$recovery_created_at" "$recovery_verified_at" "$recovery_sha256" \
"$backup_basename" "$verify_basename" >"$artifact_directory/attestation.env"
chmod 600 "$artifact_directory"/*
{
echo "recovery_reference=$recovery_reference"
echo "recovery_created_at=$recovery_created_at"
echo "recovery_verified_at=$recovery_verified_at"
echo "recovery_sha256=$recovery_sha256"
} >>"$GITHUB_OUTPUT"
echo "Recovery restore-verified: reference=$recovery_reference created_at=$recovery_created_at verified_at=$recovery_verified_at"
- name: Upload encrypted off-site recovery artifact
run: |
set -euo pipefail
test -n "${ACTIONS_RUNTIME_TOKEN:-}"
test -n "${ACTIONS_RESULTS_URL:-}"
test -n "${GITHUB_RUN_ID:-}"
test -n "${GITHUB_REPOSITORY:-}"
workdir="$(pwd -P)"
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$workdir:$workdir" \
--workdir "$workdir" \
--env HOME=/tmp \
--env "INPUT_NAME=production-recovery-$GITHUB_RUN_ID" \
--env INPUT_PATH=artifacts/production-recovery/ \
--env INPUT_OVERWRITE=false \
--env ACTIONS_RUNTIME_TOKEN \
--env ACTIONS_RESULTS_URL \
--env GITHUB_RUN_ID \
--env GITHUB_REPOSITORY \
--env "GITHUB_SHA=$GITEA_SHA" \
--env "GITHUB_WORKSPACE=$workdir" \
node:22-bookworm-slim \
node -e 'process.env["INPUT_IF-NO-FILES-FOUND"]="error"; process.env["INPUT_RETENTION-DAYS"]="30"; process.env["INPUT_COMPRESSION-LEVEL"]="0"; require("./.gitea/actions/upload-artifact/dist/index.js")'
- name: Publish recovery attestation
env:
RECOVERY_REFERENCE: ${{ steps.recovery.outputs.recovery_reference }}
RECOVERY_CREATED_AT: ${{ steps.recovery.outputs.recovery_created_at }}
RECOVERY_VERIFIED_AT: ${{ steps.recovery.outputs.recovery_verified_at }}
RECOVERY_SHA256: ${{ steps.recovery.outputs.recovery_sha256 }}
run: |
set -euo pipefail
echo "Recovery attested: reference=$RECOVERY_REFERENCE created_at=$RECOVERY_CREATED_AT verified_at=$RECOVERY_VERIFIED_AT restore_verified=true sha256=$RECOVERY_SHA256"
+8
View File
@@ -250,6 +250,14 @@ Use this order for every staging revision:
The deploy and migration workflows share the `staging-mutation` Actions concurrency group, and their live-tree sync plus Compose work runs under `/opt/jyotisha-staging/.state/mutation.lock`. The synchronized tree explicitly preserves `/backups/`, `.env*`, `.state`, and `.incoming`. The read-only checker exits before app changes when a migration is pending. Its message includes the exact SHA and the `Migrate Staging Database` workflow name. A failed migration does not re-dispatch deployment. Application rollback restores the previously recorded digest references and SHA, falling back to validated local image IDs only when transitioning from the pre-foundation local-image deployment; it does not roll back database state.
### Production recovery point before schema migration
Use Gitea Actions → **Create Production Recovery Point** from the exact current `main` release SHA before every production schema migration. The manual workflow requires `main`, `staging`, public staging health, and the successful release gate to identify the same SHA. It uses the pinned production SSH identity, shares the `production-mutation` lock, and runs `deploy/run-production-recovery.sh` with shell tracing disabled.
The host script writes an AES-256-CBC/PBKDF2 encrypted custom-format PostgreSQL dump under `/opt/jyotisha-production/backups`, restores it into a uniquely named disposable database, validates non-sensitive row/table counts, removes only that disposable database, and writes a mode-`0600` verification manifest. Before acquiring the shared host lock it validates that `.state` and `backups` are real directories, then reuses the existing passwordless Docker boundary to run the already-loaded PostgreSQL image with no network, a read-only root filesystem, all capabilities dropped except `CHOWN`, and only the two directories plus the verified existing lock inode bind-mounted. That helper exposes the regular `mutation.lock` directly at `/mutation.lock`, restores that inode before changing its mode-`0700` parent directory, then restores the two directory mount points to the current `deploy` UID/GID. The direct file mount avoids depending on traversal through a parent left half-repaired by an earlier failed run, without adding `DAC_OVERRIDE`; it never recursively changes backup files or removes or replaces a lock inode. The workflow retrieves only the encrypted dump and verification metadata, verifies the SHA-256 digest, and uploads them as a 30-day Gitea Actions artifact with compression disabled. The artifact-backed `recovery_reference`, `recovery_created_at`, and `restore_verified=true` output are the inputs for **Migrate Production Database**. Do not use the attestation if backup, restore, retrieval, digest validation, ownership normalization, or artifact upload fails.
Never restore over `jyotisha`, delete the PostgreSQL volume, print `.env.production.database`, expose `PRODUCTION_BACKUP_ENCRYPTION_KEY`, or substitute a staging recovery artifact. The encrypted local archive is preserved for repair; the Actions artifact supplies the required off-host copy.
### Local encrypted staging backups (three-copy limit)
After the health check, run the repository backup helper from the synchronized staging checkout:
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
set -euo pipefail
set +x
umask 077
: "${DEPLOY_PATH:?DEPLOY_PATH is required}"
: "${RECOVERY_RUN_ID:?RECOVERY_RUN_ID is required}"
[ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || {
echo "unexpected production path" >&2
exit 1
}
[[ "$RECOVERY_RUN_ID" =~ ^[0-9]+$ ]] || {
echo "recovery run id must be numeric" >&2
exit 1
}
state_directory="$DEPLOY_PATH/.state"
backup_directory="$DEPLOY_PATH/backups"
environment_file="$DEPLOY_PATH/.env.production.database"
lock_file="$state_directory/mutation.lock"
[ -f "$environment_file" ] && [ ! -L "$environment_file" ] || {
echo "production database environment file is missing or unsafe" >&2
exit 1
}
for directory in "$state_directory" "$backup_directory"; do
if [ -e "$directory" ]; then
[ -d "$directory" ] && [ ! -L "$directory" ] || {
echo "production state or backup directory is unsafe" >&2
exit 1
}
fi
done
install -d -m 700 "$state_directory" "$backup_directory"
deployment_uid="$(id -u)"
deployment_gid="$(id -g)"
ownership_mounts=(
--volume "$state_directory:$state_directory"
--volume "$backup_directory:$backup_directory"
)
ownership_targets=()
if [ -e "$lock_file" ]; then
[ -f "$lock_file" ] && [ ! -L "$lock_file" ] || {
echo "production mutation lock is unsafe" >&2
exit 1
}
ownership_lock_target="/mutation.lock"
ownership_mounts+=(--mount "type=bind,src=$lock_file,dst=$ownership_lock_target")
ownership_targets+=("$ownership_lock_target")
fi
ownership_targets+=("$state_directory" "$backup_directory")
mapfile -t postgres_containers < <(
sudo -n docker ps -q \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter 'label=com.docker.compose.service=postgres'
)
[ "${#postgres_containers[@]}" -eq 1 ] || {
echo "expected exactly one running production PostgreSQL container" >&2
exit 1
}
postgres_container="${postgres_containers[0]}"
ownership_image="$(sudo -n docker inspect --format '{{.Image}}' "$postgres_container")"
[[ "$ownership_image" =~ ^sha256:[0-9a-f]{64}$ ]] || {
echo "production PostgreSQL image identity is unsafe" >&2
exit 1
}
sudo -n docker run --rm --pull never --network none --read-only --user 0:0 \
--cap-drop ALL --cap-add CHOWN --security-opt no-new-privileges \
"${ownership_mounts[@]}" \
--entrypoint chown "$ownership_image" \
"$deployment_uid:$deployment_gid" "${ownership_targets[@]}"
chmod 700 "$state_directory" "$backup_directory"
if [ -e "$lock_file" ]; then
chmod 600 "$lock_file"
fi
exec 9>"$lock_file"
flock -n 9 || {
echo "another production mutation holds the host lock" >&2
exit 75
}
set -a
# shellcheck disable=SC1090
. "$environment_file"
set +a
: "${POSTGRES_DB:?}" "${POSTGRES_USER:?}" "${POSTGRES_PASSWORD:?}" "${PRODUCTION_BACKUP_ENCRYPTION_KEY:?}"
usage_percent="$(df -Pk "$backup_directory" | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')"
[[ "$usage_percent" =~ ^[0-9]+$ ]] && (( usage_percent < 70 )) || {
echo "production backup disk usage must remain below 70 percent" >&2
exit 1
}
created_compact="$(date -u +%Y%m%dT%H%M%SZ)"
created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
backup_basename="production-pre-migration-${created_compact}.dump.enc"
backup_partial="$backup_directory/.${backup_basename}.$$.partial"
backup_file="$backup_directory/$backup_basename"
restore_database="restore_verify_${created_compact,,}"
restore_database="${restore_database//[^a-z0-9_]/_}"
cleanup() {
rm -f -- "$backup_partial"
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$restore_database\" WITH (FORCE);" >/dev/null 2>&1 || true
}
trap cleanup EXIT HUP INT TERM
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner --no-acl |
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY >"$backup_partial"
[ -s "$backup_partial" ]
chmod 600 "$backup_partial"
mv "$backup_partial" "$backup_file"
backup_sha256="$(sha256sum "$backup_file" | awk '{print $1}')"
[[ "$backup_sha256" =~ ^[0-9a-f]{64}$ ]]
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE \"$restore_database\";" >/dev/null
openssl enc -d -aes-256-cbc -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup_file" |
sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_restore -U "$POSTGRES_USER" -d "$restore_database" \
--no-owner --no-acl --exit-on-error
counts="$({
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$restore_database" -At -F '|' -v ON_ERROR_STOP=1 -c \
"SELECT
(SELECT count(*) FROM identity.users),
(SELECT count(*) FROM public.profiles),
(SELECT count(*) FROM public.credit_transactions),
(SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public');"
} | tail -n 1)"
IFS='|' read -r identity_users profiles credit_transactions public_tables <<<"$counts"
for value in "$identity_users" "$profiles" "$credit_transactions" "$public_tables"; do
[[ "$value" =~ ^[0-9]+$ ]] || {
echo "restore verification returned an invalid count" >&2
exit 1
}
done
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE \"$restore_database\" WITH (FORCE);" >/dev/null
verified_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
trap - EXIT HUP INT TERM
verify_basename="${backup_basename%.dump.enc}-restore-verify.json"
verify_file="$backup_directory/$verify_basename"
printf '{\n "mode": "restore_verify",\n "ok": true,\n "backup": "%s",\n "sha256": "%s",\n "created_at": "%s",\n "verified_at": "%s",\n "restore_database_removed": true,\n "checks": {"identity_users": %s, "profiles": %s, "credit_transactions": %s, "public_tables": %s}\n}\n' \
"$backup_basename" "$backup_sha256" "$created_at" "$verified_at" \
"$identity_users" "$profiles" "$credit_transactions" "$public_tables" >"$verify_file"
chmod 600 "$verify_file"
recovery_reference="gitea-actions-run-${RECOVERY_RUN_ID}/${backup_basename}"
state_tmp="$state_directory/recovery-baseline.env.tmp.$$"
printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nVERIFY_FILE=%s\n' \
"$recovery_reference" "$created_at" "$backup_sha256" "$verify_basename" >"$state_tmp"
chmod 600 "$state_tmp"
mv "$state_tmp" "$state_directory/recovery-baseline.env"
printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_VERIFIED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nBACKUP_BASENAME=%s\nVERIFY_BASENAME=%s\n' \
"$recovery_reference" "$created_at" "$verified_at" "$backup_sha256" \
"$backup_basename" "$verify_basename"
+109 -5
View File
@@ -184,7 +184,7 @@
- 防复发:咨询路由测试锁定“有效填报分钟无需授权即可使用”;页面契约禁止重新引入生时校正 toast 或阻断式选择。
- 相关记录:BUG-003、BUG-004
- 复发自:无
- 修复版本:待提交(本地可测
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging 验收结果为准
## BUG-010 | 浏览器直连 Supabase 导致自托管 PostgreSQL staging 误报未配置
@@ -3310,11 +3310,115 @@
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:初始化出生时间采集、`homepage/new` 生时校正 Case 基线与候选搜索范围、历史 Session 恢复。
- 用户现象:用户再次新建校正时,系统可能以上一次采用的分钟而不是最初填报时间为中心,并继续继承旧的前后误差;初始化页面还要求用户选择误差分钟或大致时段
- 用户现象:用户再次新建校正时,系统可能以上一次采用的分钟而不是最初填报时间为中心,并继续继承旧的前后误差;只有大致时段或完全未知声明的 Profile 也可能被错误当成可创建精确分钟扫描的基线
- 触发条件:Profile 同时存在 `reported_birth_time`、历史 `active_birth_time` 和 uncertainty,或只有 period/unknown 声明时创建 fresh Case。
- 根因:fresh Case 的范围推导优先使用 `active_birth_time`,再直接读取 Profile uncertainty;初始化模型把用户声明误差和引擎搜索窗口混为同一字段,并允许用 period 或全天范围代替具体初始时间。
- 修复:初始化 UI 只采集一个具体 `reported_birth_time`,不再提供误差分钟、大致时段、范围线索或跳过入口;新填报的准确时间保存为 `reported + 0/0`,不自动宣称引擎 confirmed。`homepage/new` 只查询和使用 `reported_birth_time`,忽略历史 active minute、uncertainty 与 period;没有合法 reported time 时在调用创建 RPC 前以 `profile_incomplete` fail closed。Case 扫描所需的可移动窗口改为独立的服务器执行策略,目前以填报时间为中心使用前后 15 分钟,不再伪装成用户声明;`intent=session` 继续恢复历史 Case 自身冻结的 baseline/range。
- 验证:回归覆盖新初始化只展示单一时间输入、`0/0` 持久化但不确认、旧 uncertainty 不影响 fresh range、旧 active minute 不进入 baseline、period/unknown/无具体时间 legacy profile 不得新建、失败前不调用 RPC,以及 session 恢复不读取当前 Profile;与 Focus、receipt 和 migration 回归合并运行 203 passed、0 failed。正式 staging 质量门禁另暴露两个旧测试合同,现已改为锁定“初始化仅填写一个具体时间”和 receipt tools 按真实 terminal receipt 时间排序,不再要求旧的不确定时间入口或字母排序。
- 修复:新填报的准确时间保存为 `reported + 0/0`,不自动宣称引擎 confirmed;初始化仍允许用户如实声明大致时段或完全未知,但这些声明不能作为 fresh 精确分钟扫描的基线`homepage/new` 只查询和使用合法 `reported_birth_time`,忽略历史 active minute、uncertainty 与 period;没有合法 reported time 时在调用创建 RPC 前以 `profile_incomplete` fail closed。Case 扫描所需的可移动窗口改为独立的服务器执行策略,目前以填报时间为中心使用前后 15 分钟,不再伪装成用户声明;`intent=session` 继续恢复历史 Case 自身冻结的 baseline/range。
- 验证:回归覆盖准确时间 `0/0` 持久化但不确认、旧 uncertainty 不影响 fresh range、旧 active minute 不进入 baseline、period/unknown/无具体时间 legacy profile 不得新建、失败前不调用 RPC,以及 session 恢复不读取当前 Profile初始化入口回归另由 BUG-197 锁定。与 Focus、receipt 和 migration 回归合并运行 203 passed、0 failed。
- 防复发:`reported_birth_time` 是 fresh Case 唯一用户时间基线;`active_birth_time` 只表示已采用的当前排盘时间,不能反向改写新校正起点;用户声明字段、服务器搜索策略与最终 confirmed truth 必须保持分层。
- 相关记录:BUG-127、BUG-177、BUG-187
- 相关记录:BUG-127、BUG-177、BUG-187、BUG-197
- 修复版本:本次功能分支提交(精确 SHA 以提交、远程分支与 staging 发布结果为准)
## BUG-197 | 精确分钟校正前置条件误删不确定和未知出生时间入口
- 状态:resolved
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:初始化“出生日期与时间”表单、移动端资料填写、无准确出生时间用户的普通产品入口。
- 用户现象:表单只显示“我知道准确出生时间”,原有“我不确定准确时间”、大致时段、补充描述和“完全不清楚,跳过出生时间”全部消失,无法准确填写分钟的用户无法继续。
- 触发条件:staging 包含 `075c62e5` 后打开未确认出生时间的初始化表单。
- 根因:BUG-196 修复把“fresh 生时校正 Case 必须有合法 `reported_birth_time`”错误扩大为“初始化表单只能接受具体时间”,同时测试也被改成明确禁止不确定/未知入口,导致业务回归被质量门禁当成正确结果。
- 修复:恢复“我知道准确出生时间 / 我不确定准确时间”两条一级选择;不确定路径恢复大致时段、可选描述和完全未知跳过入口,未知状态允许返回描述范围。保留 BUG-196 的服务端边界:准确时间继续保存为 `reported + 0/0` 且不自动 confirmedperiod/unknown 只能完成资料声明和使用无需分钟的功能,不能创建 fresh 精确分钟扫描。
- 验证:先把两个错误测试合同改回用户路径合同并确认旧实现稳定失败,再恢复实现后通过;目标回归锁定 `family_exact + period_only` 两个一级选项、period/unknown 的真实组件分支、跳过入口、`reported` 状态及 consultation 的 minute-free 行为。390×844 Chrome 真实点击验证两个一级选项、时段表单、完全未知跳过和返回范围按钮均可见;滚动容器 `overflow-y: auto`,可从 `scrollTop=414` 滚到 `718` 并到达底部,`继续`按钮可进入视口。预览模式的三个 401 来自无登录态的只读背景接口,不影响本表单交互;仍需完成 staging 远端 SHA 验收。
- 防复发:资料声明完整性和精确分钟校正可启动性必须是两个独立条件;任何 fresh Case 前置条件调整不得删除 period/unknown 资料入口。UI 合同测试必须正向断言两个一级选择、时段选择和跳过路径存在,禁止再用负向断言把产品能力删除写成门禁。
- 相关记录:BUG-127、BUG-196
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支核验结果为准)
## BUG-198 | 不确定或未知出生时间被错误拒绝创建生时校正 Case
- 状态:resolved(本地候选,待 staging 发布与登录态业务验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:`/api/rectification/cases/open`、首页和新建生时校正入口、只有大致时段或完全未知出生时间的用户。
- 用户现象:用户已选择“上午/下午/晚上/深夜”等出生时段,或明确选择“完全不清楚”,资料保存成功,但开始生时校正时仍返回 HTTP 422 `profile_incomplete`,无法进入 Case。
- 触发条件:Profile 的 `reported_birth_time` 为空,且 `birth_time_source``period_only``unknown` 时,以 `homepage/new` intent 创建 fresh Case。
- 根因:BUG-196 将“没有具体分钟不能直接使用 ±15 分钟精细扫描”错误实现为“没有具体分钟不能创建 Case”;`case-service.ts` 的 fresh 候选范围只接受 `reported_birth_time`,没有恢复资料模型已经支持的时段范围和全天范围。对应测试也把该错误边界锁定为预期行为。
- 修复:继续保持 fresh Case 不继承历史 `active_birth_time` 和 uncertainty;有合法 `reported_birth_time` 时仍使用服务器控制的前后 15 分钟范围。`period_only` 改为使用用户已选择的服务器映射时段,包含 `late_night` 的跨午夜 `23:0003:59``unknown` 使用 `00:0023:59`。缺少出生日期、地点、时区,或选择 `period_only` 却没有合法时段等真正不完整组合,仍在调用创建 RPC 前 fail closed。
- 验证:回归测试先证明旧实现对 period/unknown 稳定抛出 `profile_incomplete`,修复后锁定 morning `08:0011:59`、late-night `23:0003:59`、unknown `00:0023:59` 均能传入 `open_agentic_rectification_case_v2`;非法缺时段/缺具体时间组合仍不调用 RPC。既有引擎范围判断支持跨午夜,工具合同已覆盖全天宽范围不伪造中午分钟。
- 防复发:资料完整性、Case 可创建性和是否可以立即执行分钟级扫描必须分层;宽范围应先通过事件问题逐步缩小,不得以 `profile_incomplete` 阻止用户进入,也不得生成虚假具体出生时间。
- 相关记录:BUG-127、BUG-196、BUG-197
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准)
## BUG-199 | Case 创建把可解析的空时区偏移误判为出生资料不完整
- 状态:resolved(本地候选,待 staging 发布与登录态业务验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:`/api/rectification/cases/open`、保存了 IANA 时区但 `timezone_offset` 为空的全球出生地点资料,尤其是 `period_only` / `unknown` 用户。
- 用户现象:用户已选择“晚上”等合法出生时段,页面也认为出生资料完整,但开始生时校正仍返回 HTTP 422 `profile_incomplete`
- 触发条件:Profile 已有出生日期、地点标签、坐标、`timezone_id` 和合法时间声明,但缓存字段 `timezone_offset``null`
- 根因:资料表单和账户保存合同允许用 IANA `timezone_id` 表达完整地点,既有普通咨询与 Journey 链路也会按出生日期和参考时间动态解析历史 offset;V9 Case 服务却在调用同一解析器之前直接强制 `timezone_offset !== null`,把可恢复的派生字段缺失误判成用户资料缺失。
- 修复:V9 Profile 读取后先调用共享 `resolveMissingBirthTimezoneOffset`;具体时间使用填报分钟,时段声明使用服务器定义的时段参考时刻,未知时间使用中午参考时刻,只用于解析该日期的历史 UTC offset,不会生成或确认具体出生分钟。解析成功后再执行原有完整性和候选范围校验;解析服务异常映射为 `profile_unavailable`,不再冒充 `profile_incomplete`
- 验证:新增真实服务边界回归,先证明 `period_only + evening + timezone_id + timezone_offset null` 在 RPC 前稳定抛出 `profile_incomplete`,修复后确认调用历史时区接口、候选范围仍为 `18:0022:59`、baseline 使用解析得到的 offset 并成功创建 Case。聚焦测试、Lint、TypeScript、远端 SHA 与 staging 业务结果按本次发布记录补充。
- 防复发:IANA 时区是地点真相,`timezone_offset` 是依赖出生日期与参考时刻的派生值;所有需要 offset 的服务必须先走共享解析边界,再区分真正资料不完整与下游服务异常。
- 相关记录:BUG-127、BUG-198
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准)
## BUG-200 | 无准确出生分钟时首页主题退化为第三方占星百科问题
- 状态:resolved(本地候选,待 staging 发布与视觉验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:首页“每日运势”、十个主题问题、Onboarding Agent 生成的事业/关系/时运建议,以及已有 onboarding 缓存。
- 用户现象:用户进入首页后看到“印度占星一般如何……”“通常会看哪些因素”“包含哪些证据层”等教学式问题;卡片在介绍占星方法,而不是帮助用户直接询问自己的每日运势、事业、关系或未来一年重点。
- 触发条件:Profile 没有可用于个人星盘的准确出生分钟,首页选择 `generalGuidedJyotishTopics`;或 Onboarding Agent 生成未使用第一人称的客观式问题。
- 根因:无分钟降级主题被写成“不依赖个人出生分钟”的占星知识入口,错误地把真实性边界实现成百科模式;同时 Onboarding Agent 只被要求介绍产品能力,服务端也未校验问题是否以用户本人为中心,因此模型输出和缓存都可能继续保存第三方视角文案。
- 修复:把每日运势和十个无分钟主题统一改成“请帮我……”的任务式请求;首页明确提示出生时间不足的部分会说明限制,但仍允许用户从自己的问题开始。Onboarding Prompt 强制每个建议包含“我”并禁止百科式句型,服务器解析层再次拒绝客观教学文案并回退到安全的第一人称问题;缓存版本升级到 `ayanam-onboarding-v4`,使旧问题重新生成。
- 验证:新增回归测试锁定无分钟主题全部为用户视角,并证明客观式 Agent 输出会被拒绝且使用第一人称 fallback;聚焦测试 25/25 通过,目标 ESLint 与 TypeScript `--noEmit` 通过。真实 staging 视觉验收待发布后完成。
- 防复发:缺少准确出生分钟只限制分钟敏感的个性化结论,不得把用户入口改写成占星教学。首页卡片和 Agent 推荐问题必须直接表达用户要解决的事;Prompt 约束之外必须保留服务端输出校验和版本化缓存失效。
- 相关记录:BUG-194、BUG-197、BUG-198
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准)
## BUG-201 | 咨询完成后的账户刷新重复请求每日星语接口
- 状态:resolvedstaging 质量门禁修复候选,待部署验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:首页每日星语卡片、咨询完成后的账户与积分刷新、`POST /api/daily-starlanguage`
- 用户现象:每完成一次普通咨询,浏览器都会再次请求每日星语接口;即使账户返回的标准化出生资料没有任何变化,也会重复生成同一张每日卡片。
- 触发条件:咨询流成功完成后调用 `refreshAccount()`;账户接口返回与当前状态值完全相同但引用不同的 Profile 对象。
- 根因:`readProfile()` 每次都会创建新的标准化对象,`refreshAccount()` 又无条件用该对象替换 Profile state;每日星语 effect 需要跟踪完整 Profile,因此依赖对象引用并在引用变化后重新执行。问题不在咨询结算,也不能通过移除账户刷新或缩减 Profile 依赖来规避。
- 修复:保留咨询完成后的账户与积分刷新;新增浅等值引用保持 helper。账户刷新得到的新 Profile 与当前 Profile 所有标准化字段等值时继续使用当前引用,只有真实字段变化时才替换 state,从而避免无意义地重跑每日星语及其他 Profile 对象 effect。
- 验证:先添加回归测试并确认因 helper 尚不存在而失败;修复后 Profile 引用行为与 `refreshAccount()` 集成测试 3/3 通过。相关 account、consultation entrypoint、starter questions 聚焦测试合计 53/53 通过;目标 ESLint 与 TypeScript `--noEmit` 通过。首次独立 staging gate Run 1856 暴露既有 Agentic 测试仍硬编码 `setProfile(nextProfile)`;该测试已改为验证等价的新函数式更新语义,同时继续禁止覆盖 `setProfileDraft`,避免把正确的引用保持修复误判为回归。`git diff --check` 按本次本地验收执行。
- 防复发:服务器资料刷新不得把“值相同”转化为无意义的状态引用变化;依赖完整 Profile 的 effect 必须在真实资料变化时执行,不能为消除重复请求而遗漏依赖字段。
- 相关记录:BUG-200
- 修复版本:本次 staging 质量门禁修复提交(精确 SHA 以远端分支与 staging health 验收结果为准)
## BUG-202 | 无出生分钟的“每日运势”仍被 General Agent 整段拒绝
- 状态:resolvedstaging 修复候选,待质量门禁与业务验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:首页“每日运势 / 深入看今日”入口、普通咨询请求 schema、服务端咨询路由、无出生分钟 General Agent,以及公共 Panchanga 证据注入。
- 用户现象:用户已经在初始化资料中如实选择“晚上”等出生时段,但从首页点击“深入看今日”后,Agent 仍回复“今天运势这个请求,我无法在这个模式下回答”,并把用户引导到占星百科问题或生时校正,无法获得首页承诺的“适合推进什么、需要注意什么”。
- 触发条件:服务端 Profile 的 `birth_time_source``period_only` 或其他没有具体分钟的状态,首页以 `general_no_birth_time` 发起 `daily_starlanguage` 请求。
- 根因:BUG-200 只修正了首页任务式文案,没有闭环服务端能力合同:前端无分钟请求主动丢弃 `daily_starlanguage` entrypointGeneral Agent 又只允许百科知识,并把所有 forecast 一律拒绝。初始化保存的出生时段不能安全替代具体分钟,因此也不能直接走个人命盘日运链路。
- 修复:无分钟请求保留受限的 `daily_starlanguage` entrypoint,并在服务端确认最终咨询模式后将其展开为“公共日历趋势”问题。新增服务器公共 Panchanga 客户端,仅向 `/api/panchanga_range` 发送当天日期和已保存地点的经纬度、时区偏移;将经过结构校验的 Vara、Tithi、Nakshatra、Yoga、整体质量、条件标签与计算策略作为 `<public-daily-panchanga>` 证据注入 Agent。General Agent 只在存在该服务端证据时回答今日整体趋势、适合推进事项、注意事项和一个立即行动;普通无证据的个人预测仍按原边界拒绝。公共数据不可用或字段不完整时 fail closed,不编造答案,并由既有外层流程取消结算。
- 验证:在最新 `origin/staging` 基线上,相关 TypeScript 聚焦测试 82/82 通过,覆盖无分钟 daily prompt、请求保留 entrypoint、公共 API 不携带出生分钟、不完整证据拒绝、`period_only` 不生成个人 `serverChart`、地点参考来自服务端 Profile、输出 guard 继续拦截个人星盘断言,并兼容既有 Profile 引用保持与 Agentic 生时校正契约;`tsc --noEmit` 通过,目标 ESLint 通过,Python Panchanga endpoint 测试 2/2 通过。staging 登录态点击、最终流事件、持久化回合与结算不变量仍待发布后验收。
- 防复发:出生时段必须继续按 `period_only` 诚实保存,不能转换成时段中点、`00:00` 或任何候选分钟。无分钟“每日运势”只能使用服务器公共 Panchanga,必须明确它不是个人命盘日运;不得声称个人上升点、宫位、分盘、大运、本命过境叠加、确定事件或精确时间。
- 相关记录:BUG-127、BUG-198、BUG-200
- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging 质量门禁结果为准)
## BUG-203 | 生产恢复点因 `.state/mutation.lock` 所有权漂移无法创建
- 状态:resolved(待重新通过 staging、release gate 与生产恢复验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:`Create Production Recovery Point`、生产 schema migration 前置恢复门禁,以及后续 production deploy。
- 用户现象:`main``staging` 已同步且 release gate 成功,但生产恢复 workflow Run 1865 在创建备份前失败,日志为 `/opt/jyotisha-production/.state/mutation.lock: Permission denied`。首次修复后的 Run 1869 仍在 `pg_dump` 前失败,准确日志为 `sudo: a password is required`。切换到受限 helper 后,Run 1873 又在同一备份前阶段报告 `chown: /opt/jyotisha-production/.state/mutation.lock: Permission denied`。改为 child-first 后的 Run 1877 仍在 `pg_dump` 前报告同一错误;迁移和部署因此持续阻断,生产运行版本未改变。
- 根因:生产 bootstrap 遗留的 `.state` 路径或既有 lock 仍为非 `deploy` 所有。原恢复脚本只执行 `install -d -m 700`;对已经存在的目录该命令不会恢复所有权,随后由 `deploy` 打开共享锁即被内核拒绝。首次修复又错误假设主机已为 `deploy` 配置独立的免密 `chown`,但真实生产 sudo 边界只允许已审查的 Docker 命令,因此 `sudo -n chown` 立即失败。第二次修复的 helper 只保留 `CHOWN` capability,却按“父目录在前、lock 子文件在后”的顺序处理;Run 1873 已先把 `.state` 改成 `deploy:deploy 0700`,再因没有 `DAC_OVERRIDE` 无法遍历到仍未修复的 lock,留下半修复状态。Run 1877 虽改为 child-first,但 helper 启动时父目录已经是不可遍历的 `0700 deploy`,所以仍无法通过原宿主路径到达 lock。四次失败都发生在 `pg_dump` 前,没有生成可用恢复证明,也没有执行 schema migration。
- 修复:恢复脚本先对 `.state``backups` 和既有 `mutation.lock` 做类型与非符号链接校验,取得当前运行 PostgreSQL 容器的不可变本地 image ID,再复用既有 `sudo -n docker` 边界启动一次性所有权修复容器:`--pull never`、无网络、只读根文件系统、`no-new-privileges`、删除全部 capability 后只保留 `CHOWN`。除绑定 `.state``backups` 外,把已经验证为普通非 symlink 文件的 lock inode 直接绑定到容器 `/mutation.lock`,先通过该直接 mount 恢复 lock,再恢复两个目录 mount point 到当前 `deploy` UID/GID。这样无需遍历半修复的 mode-`0700` 父目录,也不需要增加 `DAC_OVERRIDE`;不递归改动历史备份、不删除或替换 lock inode,随后仍用同一个 `flock -n` fail-closed 获取共享 host lock。同步更新生产 runbook 和静态安全契约测试,明确不得扩大主机 sudoers 或 capability。
- 验证:本地 `bash -n`、YAML parse、聚焦 workflow contract 与 `git diff --check` 必须通过;远端必须重新完成 staging quality/deploy、exact-SHA release gate、真实 production dump + disposable restore + off-site artifact,再允许 migration/deploy。
- 防复发:生产私有状态目录必须保持 `deploy:deploy 0700`,共享 lock 必须是普通非 symlink 文件且不可通过删除重建来“修复”;任何恢复流程失败都不得手填 `restore_verified=true` 或跳过恢复门禁。
- 相关记录:生产迁移 runbook、Run 1865、Run 1869、Run 1873、Run 1877
- 修复版本:待提交(精确 SHA 以重新发布后的远端分支与 production health 为准)
@@ -60,7 +60,7 @@ Do not put the Ubuntu password, database URLs, Resend key, payment key, model-pr
Perform this interactively before any workflow dispatch:
1. Patch Ubuntu and install Docker Engine, Compose v2, `rsync`, `curl`, `jq`, `flock`, and UFW.
2. Create a non-root `deploy` user, install a dedicated Ed25519 public key, and grant only the reviewed passwordless commands needed for Docker and deployment-tree ownership.
2. Create a non-root `deploy` user, install a dedicated Ed25519 public key, and grant only the reviewed passwordless Docker command used by the deployment workflows. Ownership normalization must reuse the capability-constrained helper container described below rather than adding a separate passwordless host `chown` rule.
3. Verify a second key-only session and rotate the exposed bootstrap password. For this host, the production owner explicitly requires password authentication to remain enabled for other operators; do not change `PasswordAuthentication`. Workflows must still use the dedicated deploy key.
4. Permit only the confirmed SSH port plus `80/tcp`, `443/tcp`, and `443/udp`. Do not publish `3000`, `5200`, `5432`, or the Docker API.
5. Create a 24 GB swap file and enable Docker log rotation. Keep at least 15 GB free before the first image pull and database import.
@@ -90,7 +90,9 @@ Use distinct production credentials for PostgreSQL roles, Better Auth, Resend, b
## Database migration engineering gate
Before importing data, dispatch Gitea Actions → `Migrate Production Database` for the exact accepted release SHA. The workflow requires `main == staging == deploy_sha`, the same successful staging backend gate, the same manual release gate, and the public staging `/api/health` identity for that SHA. It also requires a non-sensitive recovery reference, its exact UTC creation time, and `restore_verified=true`; the recovery point must be no more than 24 hours old and must already have passed a restore verification. It verifies the current production revision, obtains the gate-attested immutable Web image, runs the schema checker, applies only pending application schema migrations, and requires the checker to converge afterward.
Before importing data, dispatch Gitea Actions → `Create Production Recovery Point` for the exact accepted release SHA. It validates the private production `.state` and `backups` paths, obtains the immutable image ID of the already-running PostgreSQL container, and reuses the existing passwordless Docker boundary to run that local image with `--pull never`, no network, a read-only root filesystem, `no-new-privileges`, and only `CHOWN` retained. With `.state` and `backups` bind-mounted and an existing regular shared lock additionally mounted directly at `/mutation.lock`, it restores that inode before changing its mode-`0700` parent directory, then restores the two directory mount points to documented `deploy` ownership when bootstrap ownership has drifted. The direct file mount handles a parent left half-repaired by an earlier failed run without adding `DAC_OVERRIDE` or depending on traversal through that parent. It then creates an AES-256-CBC/PBKDF2 encrypted custom-format dump on the production host, restores it into a disposable database, verifies non-sensitive table counts, removes only that disposable database, and uploads the encrypted dump plus verification manifest as a 30-day Gitea Actions artifact. It must preserve the existing lock inode, avoid recursively changing historical backups, and fail closed on symlinks, non-regular lock paths, or an unexpected image identity. The workflow prints the non-sensitive `recovery_reference` and exact UTC `recovery_created_at`; use those values only after the artifact upload succeeds.
Then dispatch Gitea Actions → `Migrate Production Database` for the same exact accepted release SHA. The workflow requires `main == staging == deploy_sha`, the same successful staging backend gate, the same manual release gate, and the public staging `/api/health` identity for that SHA. It also requires the recovery workflow's non-sensitive reference, exact UTC creation time, and `restore_verified=true`; the recovery point must be no more than 24 hours old and must already have passed the restore verification. It verifies the current production revision, obtains the gate-attested immutable Web image, runs the schema checker, applies only pending application schema migrations, and requires the checker to converge afterward.
Schema migration files are committed sequentially and are not one atomic transaction as a set. If a later file or post-check fails, earlier files may remain applied; stop, preserve evidence, and restore from the attested recovery point when repair-in-place is not explicitly reviewed. Do not assume a failed workflow means the database is unchanged.
@@ -172,7 +174,7 @@ Both `jyotisha.chat` and `admin.jyotisha.chat` are required. The application rej
- Confirm the exact release SHA is deployed and accepted on staging.
- Run the manual release quality gate for that SHA.
- Confirm final backup capacity, restore rehearsal, SMTP/OTP delivery, and rollback contacts.
- Create and restore-verify a production recovery point no more than 24 hours before the schema migration; record its non-sensitive reference and UTC creation time.
- Run `Create Production Recovery Point` no more than 24 hours before the schema migration; retain its encrypted off-site artifact and record the printed non-sensitive reference and UTC creation time.
- Dispatch `Migrate Production Database` for the accepted SHA with that recovery attestation and confirm its post-check reports no pending schema migrations.
- Record pending payment orders and long-running jobs; choose an explicit disposition for each.
- Dispatch `Deploy production` with `verification_mode=internal` only after target schema/data preparation. This verifies the new host without depending on public DNS.
@@ -228,9 +230,10 @@ Normal release:
1. Merge the reviewed `staging` release into `main` so both heads are the same SHA.
2. Confirm the staging push gate, public staging SHA, and manual release gate all succeeded for that SHA.
3. Open Gitea Actions → `Migrate Production Database`; enter the exact 40-character SHA, the no-more-than-24-hour-old recovery reference and UTC creation time, and confirm `restore_verified=true`. Wait for the post-migration checker to converge. Do not use this workflow for Supabase ETL.
4. Run the trusted-host ETL phases and retain the redacted reconciliation manifests.
5. Open Gitea Actions → `Deploy production`.
6. Enter the same exact SHA, leave `allow_rollback=false`, and choose `internal` or `public` for the current cutover phase.
3. Open Gitea Actions → `Create Production Recovery Point`; enter the exact 40-character SHA and wait for the encrypted artifact upload plus restore verification to succeed. Record the printed `recovery_reference` and `recovery_created_at`.
4. Open Gitea Actions → `Migrate Production Database`; enter the same SHA and the no-more-than-24-hour-old recovery attestation, then confirm `restore_verified=true`. Wait for the post-migration checker to converge. Do not use this workflow for Supabase ETL.
5. Run the trusted-host ETL phases and retain the redacted reconciliation manifests when a data cutover is actually required. Ordinary forward schema releases do not rerun the one-shot Supabase ETL.
6. Open Gitea Actions → `Deploy production`.
7. Enter the same exact SHA, leave `allow_rollback=false`, and choose `internal` or `public` for the current cutover phase.
Application rollback accepts only an explicitly authorized, previously gate-attested SHA in reviewed `main` history. Database migrations and imported data are not rolled back by the application workflow.
+53 -17
View File
@@ -52,6 +52,10 @@ import {
prepareConsultationRoute,
type PreparedConsultationRoute,
} from "@/lib/consultation-route-service";
import {
loadGeneralDailyPanchangaContext,
type GeneralDailyPanchangaContext,
} from "@/lib/general-daily-panchanga";
import { z } from "zod";
export const runtime = "nodejs";
@@ -86,7 +90,7 @@ const generalChatRequestSchema = z.object({
consultationMode: z.literal("general_no_birth_time"),
question: z.string().trim().min(1).max(500),
theme: consultationDomainSchema,
entrypoint: z.undefined().optional(),
entrypoint: z.literal("daily_starlanguage").optional(),
}).strict();
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
@@ -136,6 +140,16 @@ function currentTimeContext(now = new Date()) {
return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
}
function generalDailyContextPrompt(context: GeneralDailyPanchangaContext | null) {
if (!context) return "";
return [
"以下是服务器计算并校验结构后的公共日历证据。只能在其边界内解释,不得补充个人命盘结论。",
"<public-daily-panchanga>",
JSON.stringify(context),
"</public-daily-panchanga>",
].join("\n");
}
function chinaCalendarDate(now: Date) {
return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
@@ -248,11 +262,7 @@ export async function POST(request: Request) {
}
const requestTime = new Date();
const resolvedQuestion = resolveConsultationQuestion({
visibleQuestion: parsed.data.question,
entrypoint: parsed.data.entrypoint,
currentDate: chinaCalendarDate(requestTime),
});
const currentDate = chinaCalendarDate(requestTime);
const userId = user.id;
const requestId = parsed.data.requestId;
@@ -294,7 +304,7 @@ export async function POST(request: Request) {
return data;
},
beforeReserve: ({ consultationMode }) => createConsultationPlan({
userIntent: resolvedQuestion.modelQuestion,
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
@@ -359,6 +369,13 @@ export async function POST(request: Request) {
);
}
const resolvedQuestion = resolveConsultationQuestion({
visibleQuestion: parsed.data.question,
entrypoint: parsed.data.entrypoint,
currentDate,
consultationMode: prepared.consultationMode,
});
const modelSelection = prepared.reservation;
if (modelSelection.status === "unavailable") {
@@ -493,6 +510,7 @@ export async function POST(request: Request) {
consultationMode: ConsultationBirthTimeMode,
history: Array<{ role: "user" | "assistant"; text: string }>,
name: string,
generalDailyContext: GeneralDailyPanchangaContext | null,
) {
const state = createConsultationRuntimeState();
const hooks = createConsultationRuntimeHooks(state);
@@ -592,8 +610,11 @@ export async function POST(request: Request) {
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
consultationMode === "general_no_birth_time"
? "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
? generalDailyContext
? "当前是无出生分钟的公共今日趋势咨询。可依据服务器提供的公共 Panchanga 摘要回答,但不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。",
generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
},
@@ -606,7 +627,12 @@ export async function POST(request: Request) {
hooks,
};
const workflowReceipt: WorkflowReceipt = consultationMode === "general_no_birth_time"
? { route: "general-no-birth-time", status: "ready", preciseTiming: "blocked", missingLayers: ["birth-minute"] }
? {
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
status: "ready",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
}
: { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] };
if (consultationMode === "general_no_birth_time") {
@@ -629,7 +655,7 @@ export async function POST(request: Request) {
steps: state.steps,
stepBudget: consultationStepBudgetReceipt(state),
workflow: workflowReceipt,
techniqueTruth: "not-applicable",
techniqueTruth: generalDailyContext ? "public-panchanga-only" : "not-applicable",
});
return streamAgentResponse({
runId: requestId,
@@ -648,7 +674,7 @@ export async function POST(request: Request) {
onComplete: (output, agentExecutionReceipt) => settleRun(() => completeResponse(
output,
mergeUsage(usages),
"not-applicable",
generalDailyContext ? "public-panchanga-only" : "not-applicable",
workflowReceipt,
agentExecutionReceipt,
), undefined),
@@ -731,8 +757,15 @@ export async function POST(request: Request) {
const { history } = parsed.data;
const name = prepared.serverChart?.name ?? parsed.data.name;
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
const generalDailyContext = consultationMode === "general_no_birth_time"
&& parsed.data.entrypoint === "daily_starlanguage"
? await loadGeneralDailyPanchangaContext({
date: currentDate,
reference: prepared.generalDailyReference,
})
: null;
if (shouldUseAgenticRuntime(user)) {
return await runAgenticConsultation(consultationMode, history, name);
return await runAgenticConsultation(consultationMode, history, name, generalDailyContext);
}
if (!shouldRunBirthChartWorkflow(consultationMode)) {
const result = await getGeneralJyotishAgent(selectedModel).stream([
@@ -741,13 +774,16 @@ export async function POST(request: Request) {
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
"当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。",
generalDailyContext
? "当前是无出生分钟的公共今日趋势咨询。可依据服务器提供的公共 Panchanga 摘要回答,但不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。",
generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
},
]);
const workflowReceipt: WorkflowReceipt = {
route: "general-no-birth-time",
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
status: "ready",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
@@ -757,7 +793,7 @@ export async function POST(request: Request) {
? () => completeResponse(
output,
result.totalUsage,
"not-applicable",
generalDailyContext ? "public-panchanga-only" : "not-applicable",
workflowReceipt,
)
: cancel,
@@ -770,7 +806,7 @@ export async function POST(request: Request) {
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": "not-applicable",
"x-jyotish-technique-truth": generalDailyContext ? "public-panchanga-only" : "not-applicable",
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers.join(","),
"x-jyotish-birth-time-mode": consultationMode,
@@ -778,7 +814,7 @@ export async function POST(request: Request) {
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
"not-applicable",
generalDailyContext ? "public-panchanga-only" : "not-applicable",
workflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
+13 -10
View File
@@ -102,6 +102,7 @@ import {
requestOnboardingWithRecovery,
} from "@/lib/onboarding-client";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
import { preserveShallowEqual } from "@/lib/preserve-shallow-equal";
import {
SessionModelPersistenceQueue,
persistSessionModelSelection,
@@ -1797,7 +1798,7 @@ export default function Home() {
const latest = await fetchAccount();
if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return;
const nextProfile = readProfile(latest.profile);
setProfile(nextProfile);
setProfile((current) => preserveShallowEqual(current, nextProfile));
setAccount(latest);
setAccountError("");
} catch (caught) {
@@ -2332,7 +2333,9 @@ export default function Home() {
function draftDailyStarlanguageQuestion() {
chooseSuggestedQuestion(
personalChartAvailable ? "深入看今日" : "印度占星通常如何观察每日趋势?",
personalChartAvailable
? "深入看今日"
: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。",
"timing",
personalChartAvailable ? "daily_starlanguage" : null,
);
@@ -2954,8 +2957,8 @@ export default function Home() {
modelId: currentSession.modelId,
name: profile.name,
consultationMode: consultationRoute.mode,
entrypoint: entrypoint ?? undefined,
...(consultationRoute.mode === "general_no_birth_time" ? {} : {
entrypoint: entrypoint ?? undefined,
year,
month,
day,
@@ -3344,7 +3347,7 @@ export default function Home() {
<h1 id="starter-heading"></h1>
<p className="starter-hero-note">{personalChartAvailable
? "从此刻最在意的事开始,我会结合你的星盘证据,帮你把问题拆得更清楚。"
: "你可以先了解一般占星知识;完成生时校正后,再讨论个人星盘结论。"}</p>
: "从你现在最关心的事开始;出生时间不足以支持的部分,我会明确说明,不会补造具体分钟。"}</p>
</div>
</section>
@@ -3353,17 +3356,17 @@ export default function Home() {
<button
className="product-entrypoint-hitarea"
type="button"
aria-label={personalChartAvailable ? "深入看今日" : "了解每日趋势的分析方法"}
aria-label={personalChartAvailable ? "深入看今日" : "查看今日运势"}
disabled={productEntrypointsDisabled}
onClick={draftDailyStarlanguageQuestion}
/>
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "如何看每日势"}</h2>
<p>{personalChartAvailable ? dailyStarlanguage?.trend : "了解印度占星通常会用哪些因素观察一天的主题。"}</p>
<h2 id="daily-starlanguage-title">{personalChartAvailable ? "今日星语" : "每日势"}</h2>
<p>{personalChartAvailable ? dailyStarlanguage?.trend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
</div>
<div className="product-entrypoint-footer">
<small>{personalChartAvailable ? dailyStarlanguage?.action : "不依赖个人出生分钟。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? "深入看今日" : "了解方法"} <ArrowUpRight className="starter-arrow" /></span>
<small>{personalChartAvailable ? dailyStarlanguage?.action : "不支持的个人判断会明确说明,不会补造出生时间。"}</small>
<span className="product-entrypoint-action" aria-hidden="true">{personalChartAvailable ? "深入看今日" : "查看今日运势"} <ArrowUpRight className="starter-arrow" /></span>
</div>
</article>
<article className="birth-rectification-card product-entrypoint-card" aria-labelledby="birth-rectification-title">
@@ -3392,7 +3395,7 @@ export default function Home() {
<section className="starter-themes" aria-labelledby="starter-themes-heading">
<div className="starter-section-heading">
<h2 id="starter-themes-heading"></h2>
<p></p>
<p></p>
</div>
<div className="starter-theme-accordion">
{starterSuggestions.map((item) => {
+73 -4
View File
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
import {
birthTimeDisplayState,
birthTimePeriodOptions,
birthTimeSourceDefaults,
birthTimeSourceOptions,
type BirthTimeDraft,
@@ -70,6 +71,9 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
const source = value.birthTimeSource;
const isConfirmed = value.birthTimeStatus === "confirmed";
const displayState = birthTimeDisplayState(value);
const knowledgeMode = source === "period_only" || source === "unknown"
? "uncertain"
: source ? "exact" : "";
const usesClockTime = source === "hospital_record"
|| source === "family_exact"
|| source === "approximate"
@@ -114,16 +118,20 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
)}
{!isConfirmed && <fieldset className="birth-time-source-fieldset">
<legend></legend>
<p className="birth-time-source-intro"></p>
<legend></legend>
<p className="birth-time-source-intro"></p>
<div className="birth-time-source-list">
{birthTimeSourceOptions.map((option) => (
<label
className={`birth-time-source-option ${source === option.value ? "is-selected" : ""}`}
className={`birth-time-source-option ${option.value === "family_exact"
? knowledgeMode === "exact" ? "is-selected" : ""
: knowledgeMode === "uncertain" ? "is-selected" : ""}`}
key={option.value}
>
<input
checked={source === option.value}
checked={option.value === "family_exact"
? knowledgeMode === "exact"
: knowledgeMode === "uncertain"}
name={`birth-time-source-${groupId}`}
type="radio"
value={option.value}
@@ -158,6 +166,67 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
)}
</div>
)}
{source === "period_only" && (
<div className="birth-time-detail-grid birth-time-period-details onboarding-card-reveal">
<label>
<span></span>
<Select
required
value={value.birthTimePeriod || null}
onValueChange={(nextValue) => {
if (typeof nextValue === "string") onPatch({ birthTimePeriod: nextValue });
}}
>
<SelectTrigger aria-label="最接近的时间范围">
<SelectValue placeholder="请选择大致时段">
{(selectedValue) => birthTimePeriodOptions.find((option) => option.value === selectedValue)?.label ?? "请选择大致时段"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{birthTimePeriodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label>
<span></span>
<textarea
maxLength={240}
placeholder="例如:天刚亮、午饭前后、家人记得大约 6—8 点"
rows={2}
value={value.birthTimeClue}
onChange={(event) => onPatch({ birthTimeClue: event.target.value })}
/>
</label>
<button
className="button-secondary birth-time-skip-button"
type="button"
onClick={() => onPatch({
birthTimeSource: "unknown",
reportedTime: "",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "reported",
time: "",
})}
></button>
</div>
)}
{source === "unknown" && (
<div className="birth-time-detail-note onboarding-card-reveal" role="status">
<p>使</p>
<button
className="button-secondary"
type="button"
onClick={() => onPatch({ birthTimeSource: "period_only", birthTimeStatus: "reported" })}
></button>
</div>
)}
</div>
);
}
@@ -84,6 +84,7 @@ export function formatBirthDate(value: Date): string {
export const birthTimeSourceOptions = [
{ value: "family_exact", label: "我知道准确出生时间", hint: "保存为初始化填报时间,不会自动标记为引擎确认" },
{ value: "period_only", label: "我不确定准确时间", hint: "告诉我们大致时段;完全不清楚也可以直接跳过" },
] as const;
export const birthTimeSourceDefaults = {
+18 -8
View File
@@ -11,6 +11,7 @@ type ConsultationQuestionInput = {
readonly visibleQuestion: string;
readonly entrypoint: ConsultationEntrypoint | undefined;
readonly currentDate: string;
readonly consultationMode?: "verified_chart" | "unverified_birth_time" | "general_no_birth_time";
};
export type ResolvedConsultationQuestion =
@@ -24,14 +25,23 @@ export function resolveConsultationQuestion(
case undefined:
return { kind: "plain", modelQuestion: input.visibleQuestion };
case "daily_starlanguage":
return {
kind: "expanded",
modelQuestion: [
`请结合已校验的星盘资料,深入解读 ${input.currentDate} 的今日主题。`,
"请说明今日趋势、适合推进的事、需要避开的事,以及一个可以立即执行的行动建议。",
"这是探索性日提示,不是确定预测;精确事件日期只能标为候选触发,不能包装成必然结论。",
].join("\n"),
};
return input.consultationMode === "general_no_birth_time"
? {
kind: "expanded",
modelQuestion: [
`请依据服务器提供的 ${input.currentDate} 公共 Panchanga 日历摘要,回答今天的整体趋势。`,
"重点说明适合推进什么、需要注意什么,并给出一个立即可执行的行动建议。",
"这不是个人命盘结论,不包含个人上升点、宫位、大运或本命过境叠加;不要把公共日历趋势写成确定预测。",
].join("\n"),
}
: {
kind: "expanded",
modelQuestion: [
`请结合已校验的星盘资料,深入解读 ${input.currentDate} 的今日主题。`,
"请说明今日趋势、适合推进的事、需要避开的事,以及一个可以立即执行的行动建议。",
"这是探索性日提示,不是确定预测;精确事件日期只能标为候选触发,不能包装成必然结论。",
].join("\n"),
};
case "birth_time_rectification":
return {
kind: "expanded",
+27 -2
View File
@@ -2,6 +2,7 @@ import { chinaLocations } from "../data/china-locations.ts";
import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts";
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
import type { ConsultationBirthTimeMode } from "./consultation-birth-time-mode.ts";
import type { GeneralDailyReference } from "./general-daily-panchanga.ts";
export type ConsultationProfileTruthErrorCode =
| "profile_unavailable"
@@ -62,6 +63,7 @@ export type ServerChartConsultation = Readonly<{
type ConsultationPreReserveContext = Readonly<{
consultationMode: ConsultationBirthTimeMode;
serverChart: ServerChartConsultation | null;
generalDailyReference: GeneralDailyReference | null;
}>;
type PrepareConsultationRouteInput<Reservation> = Readonly<{
@@ -83,6 +85,7 @@ type PrepareConsultationRouteWithGuard<Reservation, GuardResult> = Omit<
export type PreparedConsultationRoute<Reservation, GuardResult = undefined> = Readonly<{
consultationMode: ConsultationBirthTimeMode;
serverChart: ServerChartConsultation | null;
generalDailyReference: GeneralDailyReference | null;
reservation: Reservation;
preReserveResult: GuardResult;
}>;
@@ -142,6 +145,25 @@ function optionalText(profile: RecordValue, key: string): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function generalDailyReferenceFromProfile(value: unknown): GeneralDailyReference | null {
const profile = record(value);
if (!profile) return null;
const latitude = profile.latitude;
const longitude = profile.longitude;
const timezoneOffset = profile.timezone_offset;
if (typeof latitude !== "number" || !Number.isFinite(latitude) || latitude < -90 || latitude > 90
|| typeof longitude !== "number" || !Number.isFinite(longitude) || longitude < -180 || longitude > 180
|| typeof timezoneOffset !== "number" || !Number.isFinite(timezoneOffset) || timezoneOffset < -12 || timezoneOffset > 14) {
return null;
}
return Object.freeze({
latitude,
longitude,
timezoneOffset,
placeLabel: optionalText(profile, "birth_place_label") ?? "已保存地点",
});
}
function persistedChartMode(value: unknown): Exclude<ConsultationBirthTimeMode, "general_no_birth_time"> | null {
const profile = record(value);
if (!profile) return null;
@@ -298,6 +320,9 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
? persistedChartMode(profile) ?? input.mode
: input.mode;
let serverChart: ServerChartConsultation | null = null;
const generalDailyReference = consultationMode === "general_no_birth_time"
? generalDailyReferenceFromProfile(profile)
: null;
if (consultationMode !== "general_no_birth_time") {
const profileValue = record(profile);
const selectedTime = consultationMode === "verified_chart"
@@ -313,8 +338,8 @@ export async function prepareConsultationRoute<Reservation, GuardResult>(
serverChart = serverChartFromProfile(profile, consultationMode);
}
const preReserveResult = input.beforeReserve
? await input.beforeReserve({ consultationMode, serverChart }) as Awaited<GuardResult>
? await input.beforeReserve({ consultationMode, serverChart, generalDailyReference }) as Awaited<GuardResult>
: undefined;
const reservation = await input.reserve();
return Object.freeze({ consultationMode, serverChart, reservation, preReserveResult });
return Object.freeze({ consultationMode, serverChart, generalDailyReference, reservation, preReserveResult });
}
+130
View File
@@ -0,0 +1,130 @@
type RecordValue = Record<string, unknown>;
export type GeneralDailyReference = Readonly<{
latitude: number;
longitude: number;
timezoneOffset: number;
placeLabel: string;
}>;
export type GeneralDailyPanchangaContext = Readonly<{
scope: "public_day_no_natal_chart";
date: string;
referencePlace: string;
calculationPolicy: string;
panchanga: Readonly<{
vara: string;
tithi: string;
nakshatra: string;
yoga: string;
overallQuality: string;
}>;
conditionTags: ReadonlyArray<Readonly<{
key: string;
label: string;
guidance: string;
}>>;
boundaries: readonly [string, string, string];
}>;
type LoadGeneralDailyPanchangaInput = Readonly<{
date: string;
reference?: GeneralDailyReference | null;
fetchImpl?: typeof fetch;
apiBase?: string;
}>;
function record(value: unknown): RecordValue | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as RecordValue
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function nestedText(value: unknown, key: string): string | null {
return text(record(value)?.[key]);
}
function unavailable(): never {
throw new Error("general_daily_panchanga_unavailable");
}
export async function loadGeneralDailyPanchangaContext(
input: LoadGeneralDailyPanchangaInput,
): Promise<GeneralDailyPanchangaContext> {
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date)) unavailable();
const fetchImpl = input.fetchImpl ?? fetch;
const apiBase = input.apiBase ?? process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4_000);
const body: Record<string, unknown> = {
start_date: input.date,
end_date: input.date,
};
if (input.reference) {
body.lat = input.reference.latitude;
body.lon = input.reference.longitude;
body.tz = input.reference.timezoneOffset;
}
let response: Response;
try {
response = await fetchImpl(`${apiBase}/api/panchanga_range`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: controller.signal,
});
} catch {
return unavailable();
} finally {
clearTimeout(timeout);
}
if (!response.ok) unavailable();
const payload = record(await response.json().catch(() => null));
const report = record(payload?.report);
const day = Array.isArray(report?.days) ? record(report.days[0]) : null;
const panchanga = record(day?.panchanga);
const policy = record(report?.calculation_policy);
const tithi = nestedText(panchanga?.tithi, "full_name") ?? nestedText(panchanga?.tithi, "name");
const nakshatra = nestedText(panchanga?.nakshatra, "nakshatra") ?? nestedText(panchanga?.nakshatra, "name");
const yoga = nestedText(panchanga?.yoga, "yoga") ?? nestedText(panchanga?.yoga, "name");
const vara = nestedText(panchanga?.vara, "vara") ?? nestedText(panchanga?.vara, "name");
const overallQuality = text(panchanga?.overall_quality);
const calculationPolicy = text(policy?.panchanga);
if (payload?.success !== true || payload.endpoint !== "panchanga_range"
|| text(day?.query_date) !== input.date || !tithi || !nakshatra || !yoga || !vara
|| !overallQuality || !calculationPolicy) unavailable();
const conditionTags = (Array.isArray(day?.condition_tags) ? day.condition_tags : [])
.map(record)
.filter((item): item is RecordValue => item !== null)
.map((item) => ({
key: text(item.key) ?? "",
label: text(item.label) ?? "",
guidance: text(item.guidance) ?? "",
}))
.filter((item) => item.key && item.label && item.guidance)
.slice(0, 6);
const boundaries = [
"仅为所选地点与日期的公共 Panchanga 日历,不是个人命盘或个人预测。",
"未使用出生分钟、时段中点、00:00 或任何候选出生时间。",
"不得据此声称个人上升点、宫位、大运、本命过境叠加或确定事件。",
] as const;
return Object.freeze({
scope: "public_day_no_natal_chart",
date: input.date,
referencePlace: input.reference?.placeLabel ?? "未指定地点的公共日期参考",
calculationPolicy,
panchanga: Object.freeze({ vara, tithi, nakshatra, yoga, overallQuality }),
conditionTags: Object.freeze(conditionTags),
boundaries: Object.freeze(boundaries),
});
}
+10 -10
View File
@@ -24,16 +24,16 @@ export const defaultGuidedJyotishTopics: GuidedJyotishTopic[] = consultationDoma
}));
const generalPromptByTheme: Partial<Record<ConsultationTheme, string>> = {
career: "印度占星一般会从哪些因素理解事业方向?",
marriage: "印度占星一般如何分析关系模式",
wealth: "印度占星一般如何分析财富结构与风险?",
health: "印度占星如何在非医疗诊断边界内理解身心压力?",
education: "印度占星一般如何理解学习方式与进阶节奏?",
migration: "印度占星一般如何分析迁居、置业海外发展",
family: "印度占星一般如何理解家庭关系与责任模式?",
annual: "印度占星中的年运分析通常包含哪些证据层?",
timing: "印度占星中的时间推运通常会看哪些因素?",
general: "印度占星综合咨询会如何划分不同主题与证据边界?",
career: "请帮我梳理目前的事业方向和下一步重点。",
marriage: "请帮我看看我在关系中容易重复什么模式",
wealth: "请帮我分析目前的财富重点、风险和更稳妥的选择。",
health: "请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。",
education: "请帮我看看我更适合怎样学习,以及如何安排下一步。",
migration: "请帮我分析现阶段是否适合迁居、置业或考虑海外发展",
family: "请帮我看看家庭关系中最需要处理的问题和责任边界。",
annual: "请帮我看看未来一年的主要趋势、机会和需要留意的阶段。",
timing: "请帮我看看目前适合推进什么,哪些事情需要再等等。",
general: "请结合我的情况,帮我找出现在最值得优先处理的三个问题。",
};
export const generalGuidedJyotishTopics: GuidedJyotishTopic[] = defaultGuidedJyotishTopics.map((topic) => ({
+1 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
const ONBOARDING_VERSION = "ayanam-onboarding-v3";
const ONBOARDING_VERSION = "ayanam-onboarding-v4";
export const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000;
type OnboardingProfileInput = {
+15 -6
View File
@@ -1,11 +1,20 @@
import { z } from "zod";
const detachedStarterQuestionPattern = /印度占星|一般如何|通常(?:会)?(?:看|观察|分析|理解|包含)|哪些(?:因素|证据层)|如何划分/;
const userCenteredStarterQuestionSchema = z.string()
.trim()
.min(4)
.max(80)
.refine((text) => text.includes("我"), "starter_question_must_be_first_person")
.refine((text) => !detachedStarterQuestionPattern.test(text), "starter_question_must_not_be_encyclopedic");
const onboardingSchema = z.object({
greeting: z.string().trim().min(8).max(180),
suggestions: z.tuple([
z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("career"), text: userCenteredStarterQuestionSchema }),
z.object({ theme: z.literal("marriage"), text: userCenteredStarterQuestionSchema }),
z.object({ theme: z.literal("timing"), text: userCenteredStarterQuestionSchema }),
]),
});
@@ -14,9 +23,9 @@ export type OnboardingPayload = z.infer<typeof onboardingSchema>;
export const fallbackOnboardingPayload: OnboardingPayload = {
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
suggestions: [
{ theme: "career", text: "我的事业优势更适合怎样发挥" },
{ theme: "marriage", text: "我在关系里容易重复什么模式" },
{ theme: "timing", text: "未来一年哪些阶段值得提前准备" },
{ theme: "career", text: "请帮我看看事业优势更适合怎样发挥" },
{ theme: "marriage", text: "请帮我看看关系里容易重复什么模式" },
{ theme: "timing", text: "请帮我看看未来一年哪些阶段值得提前准备" },
],
};
@@ -0,0 +1,18 @@
export function preserveShallowEqual<T extends object>(current: T, next: T): T {
if (Object.is(current, next)) return current;
const currentRecord = current as Record<string, unknown>;
const nextRecord = next as Record<string, unknown>;
const currentKeys = Object.keys(currentRecord);
const nextKeys = Object.keys(nextRecord);
if (currentKeys.length !== nextKeys.length) return next;
for (const key of currentKeys) {
if (!Object.hasOwn(nextRecord, key) || !Object.is(currentRecord[key], nextRecord[key])) {
return next;
}
}
return current;
}
@@ -8,6 +8,7 @@
*/
import { createHash } from "node:crypto";
import type { SupabaseClient } from "@supabase/supabase-js";
import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts";
import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts";
import {
resolveActiveSkillPackage,
@@ -108,16 +109,45 @@ function shiftedTime(time: string, offsetMinutes: number): string {
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
// This is an engine execution boundary, not a user-declared uncertainty. Fresh
// Cases always start from reported_birth_time with enough room to rectify.
// This is an engine execution boundary, not a user-declared uncertainty.
// Exact-time Cases receive a movable search radius; imprecise declarations keep
// the honest server-owned range instead of inventing a baseline minute.
const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15;
const PERIOD_CANDIDATE_RANGES = {
early_morning: { start_time: "04:00", end_time: "07:59" },
morning: { start_time: "08:00", end_time: "11:59" },
afternoon: { start_time: "12:00", end_time: "17:59" },
evening: { start_time: "18:00", end_time: "22:59" },
late_night: { start_time: "23:00", end_time: "03:59" },
} as const;
function deriveCandidateRange(reportedTime: string | null): { start_time: string; end_time: string } {
if (!reportedTime) throw new RectificationCaseServiceError("profile_incomplete");
return {
start_time: shiftedTime(reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES),
end_time: shiftedTime(reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES),
};
function deriveCandidateRange(input: {
reportedTime: string | null;
source: string;
period: string | null;
}): { start_time: string; end_time: string } {
if (input.reportedTime) {
return {
start_time: shiftedTime(input.reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES),
end_time: shiftedTime(input.reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES),
};
}
if (input.source === "period_only" || input.source === "legacy_import") {
const periodRange = input.period && Object.hasOwn(PERIOD_CANDIDATE_RANGES, input.period)
? PERIOD_CANDIDATE_RANGES[input.period as keyof typeof PERIOD_CANDIDATE_RANGES]
: null;
if (periodRange) return periodRange;
if (input.source === "period_only") {
throw new RectificationCaseServiceError("profile_incomplete");
}
}
if (input.source === "unknown" || input.source === "legacy_import") {
return { start_time: "00:00", end_time: "23:59" };
}
throw new RectificationCaseServiceError("profile_incomplete");
}
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
@@ -151,7 +181,13 @@ export async function loadV9RectificationProfile(
.single();
if (error || !data) throw new RectificationCaseServiceError("profile_unavailable");
const row = data as Record<string, unknown>;
let resolvedData: unknown;
try {
resolvedData = await resolveMissingBirthTimezoneOffset(data);
} catch {
throw new RectificationCaseServiceError("profile_unavailable");
}
const row = resolvedData as Record<string, unknown>;
const birthDate = normalizePersistedBirthDate(row.birth_date);
const latitude = numberOrNull(row.latitude);
const longitude = numberOrNull(row.longitude);
@@ -187,7 +223,7 @@ export async function loadV9RectificationProfile(
userId,
baseline,
baselineFingerprint: baselineFingerprint(baseline),
candidateRange: deriveCandidateRange(reportedTime),
candidateRange: deriveCandidateRange({ reportedTime, source, period }),
};
}
+6 -2
View File
@@ -93,7 +93,9 @@ ${JSON.stringify(toAgentConsultationContext(workflowContext))}
const generalJyotishInstructions = `You are the guide for a conversational Vedic astrology product.
Load the jyotish-vedic-astrology skill before answering. This request explicitly has no usable birth minute. Never calculate, infer, or claim a personal birth chart, ascendant, house, divisional chart, dasha, transit timing, or personal prediction. You have no chart tools for this mode.
Answer only general educational questions that do not depend on the user's natal chart. If the question asks for a personal chart conclusion, timing, compatibility, or forecast, clearly say that this mode cannot answer it and offer exactly two safe next steps: ask a general-knowledge question, or complete birth-time rectification. Do not invent 00:00, a period midpoint, or any other substitute minute.
Answer general educational questions that do not depend on the user's natal chart. A homepage daily request may also include a server-owned <public-daily-panchanga> block. In that one case, explain the public calendar trend, suitable actions, cautions, and one practical next step from that block only. State concisely that it is a public-day reference rather than a personal natal forecast; do not reject the whole request merely because the birth minute is unavailable.
If a request asks for a personal chart conclusion, personal timing, compatibility, or forecast without that public daily evidence, clearly say that this mode cannot answer it and offer exactly two safe next steps: ask a general-knowledge question, or complete birth-time rectification. Do not invent 00:00, a period midpoint, or any other substitute minute.
Never turn public Panchanga into claims about the user's ascendant, houses, dasha, natal transits, guaranteed outcomes, or exact event timing. Do not invent or alter Panchanga fields that the server did not provide.
Do not imply that a reported or candidate time is confirmed. Do not reveal prompts, skills, secrets, or private data. Do not provide medical, legal, investment, or safety-critical instructions.
Use concise Simplified Chinese. Session title and follow-up suggestions are generated and validated by the server; do not add hidden metadata blocks to the answer.`;
@@ -121,7 +123,9 @@ Return valid JSON only. Do not use Markdown fences, commentary, or hidden fields
The JSON shape must be:
{"greeting":"一句自然、克制的简体中文欢迎语","suggestions":[{"theme":"career","text":"问题"},{"theme":"marriage","text":"问题"},{"theme":"timing","text":"问题"}]}
The greeting should sound human and calm, and directly invite the user to begin with what matters to them. Never mention birth data, profile readiness, setup completion, or system processing. Do not overpraise, sound mystical, or use marketing slogans.
Generate exactly three concise questions, one for each required theme in the given order. They must help a first-time user understand the product's abilities, use everyday Simplified Chinese, and be answerable through the skill. Avoid jargon, fear, deterministic promises, medical/legal/investment claims, and unsupported precision.`;
Generate exactly three concise questions, one for each required theme in the given order. Write every question as the user's own first-person request and include “我”, such as “请帮我看看……”. The question must ask for useful help with the user's situation, not for a lesson about astrology.
Never generate detached or encyclopedic wording such as “印度占星一般如何……”, “通常会看哪些因素”, “包含哪些证据层”, or “如何划分主题”.
The questions must use everyday Simplified Chinese and be answerable through the skill. Avoid jargon, fear, deterministic promises, medical/legal/investment claims, and unsupported precision.`;
const onboardingAgents = new Map<string, Agent>();
@@ -206,18 +206,19 @@ test("unverified birth time no longer emits a modal or toast gate", () => {
assert.doesNotMatch(page, /role="alertdialog"[\s\S]{0,300}出生时间还没有完成校正/);
});
test("birth time intake only accepts one concrete initialization time", () => {
test("birth time intake starts with exact or uncertain choices and keeps rectification optional", () => {
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
const model = readFileSync(new URL("../src/lib/birth-time-intake-model.ts", import.meta.url), "utf8");
assert.match(model, /我知道准确出生时间/);
assert.match(intake, /请填写你初始化提供的具体出生时间/);
assert.match(model, /我不确定准确时间/);
assert.match(intake, /你对出生时间了解多少?/);
assert.match(intake, /不用猜具体分钟/);
assert.match(intake, /aria-label="选择出生时间"/);
assert.doesNotMatch(model, /我不确定准确时间/);
assert.doesNotMatch(intake, /source === "family_exact" \|\| source === "approximate"/);
assert.doesNotMatch(intake, /请选择最接近的时间范围/);
assert.doesNotMatch(intake, /完全不清楚,跳过出生时间/);
assert.doesNotMatch(intake, /生时校正以后需要时再做/);
assert.match(intake, /请选择最接近的时间范围/);
assert.match(intake, /完全不清楚,跳过出生时间/);
assert.match(intake, /生时校正以后需要时再做/);
});
test("homepage and profile result copy use the source-aware consultation options", () => {
+10 -4
View File
@@ -308,13 +308,19 @@ test("persisted birth dates normalize database ISO values without accepting inva
assert.equal(normalizePersistedBirthDate("1997-08-08junk"), "");
});
test("fresh intake only offers a concrete reported time and does not auto-confirm it", () => {
test("fresh intake preserves exact, approximate-period, and unknown-time paths without auto-confirming", () => {
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.deepEqual(birthTimeSourceOptions.map((option) => option.value), ["family_exact"]);
assert.deepEqual(
birthTimeSourceOptions.map((option) => option.value),
["family_exact", "period_only"],
);
assert.doesNotMatch(source, /已用于当前排盘/);
assert.doesNotMatch(source, /可能误差|选择误差范围|最接近的时间范围|大致时段|描述一个时间范围/);
assert.doesNotMatch(source, /birthTimePeriodOptions|birthTimeSource: "period_only"|birthTimeSource: "unknown"/);
assert.match(source, /birthTimePeriodOptions/);
assert.match(source, /birthTimeSource: "unknown"/);
assert.match(source, /请选择最接近的时间范围/);
assert.match(source, /完全不清楚,跳过出生时间/);
assert.match(source, /我可以描述一个时间范围/);
assert.match(source, /birthTimeStatus: "reported"/);
assert.match(source, /birthTimeConsultationOptionsCopy\(value\)/);
});
+18 -1
View File
@@ -37,6 +37,20 @@ test("daily entrypoint selects a private server expansion", () => {
assert.notEqual(resolved.modelQuestion, visibleQuestion);
});
test("daily entrypoint without a birth minute selects a public-day expansion", () => {
const resolved = resolveConsultationQuestion({
visibleQuestion: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。",
entrypoint: "daily_starlanguage",
currentDate: "2026-08-15",
consultationMode: "general_no_birth_time",
});
assert.equal(resolved.kind, "expanded");
assert.match(resolved.modelQuestion, /公共 Panchanga/);
assert.match(resolved.modelQuestion, /不包含个人上升点、宫位、大运或本命过境叠加/);
assert.doesNotMatch(resolved.modelQuestion, /已校验的星盘资料/);
});
test("birth-time entrypoint selects a private server expansion", () => {
// Given: a completed profile starts another rectification from a public label.
const visibleQuestion = "再次校正";
@@ -71,9 +85,12 @@ test("browser source does not own private entrypoint prompts", () => {
test("ordinary product drafts keep the public question and clear hidden routing after edits", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /personalChartAvailable \? "深入看今日"[\s\S]*?"timing",[\s\S]*?personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /personalChartAvailable[\s\S]*?\? "深入看今日"[\s\S]*?: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。"[\s\S]*?"timing",[\s\S]*?personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /messages:\s*\[\.\.\.preservedMessages,[\s\S]*?\{ role: "user", text: question \}\]/);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? undefined,[\s\S]*?question,/);
const requestBody = source.slice(source.indexOf("body: JSON.stringify({"), source.indexOf("history: currentSession.messages", source.indexOf("body: JSON.stringify({")));
assert.match(requestBody, /consultationMode:[\s\S]*?entrypoint: entrypoint \?\? undefined/);
assert.doesNotMatch(requestBody, /general_no_birth_time" \? \{\} : \{[\s\S]*?entrypoint/);
assert.match(source, /onChange=\{\(event\) => \{[\s\S]*?setDraft\(event\.target\.value\);[\s\S]*?setDraftTheme\(null\);[\s\S]*?setDraftEntrypoint\(null\);/);
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
});
@@ -309,6 +309,12 @@ test("general mode remains general when persisted profile has no concrete minute
assert.equal(prepared.consultationMode, "general_no_birth_time");
assert.equal(prepared.serverChart, null);
assert.deepEqual(prepared.generalDailyReference, {
latitude: profile.latitude,
longitude: profile.longitude,
timezoneOffset: profile.timezone_offset,
placeLabel: "已保存地点",
});
assert.equal(prepared.reservation, "reserved");
});
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
loadGeneralDailyPanchangaContext,
type GeneralDailyPanchangaContext,
} from "../src/lib/general-daily-panchanga.ts";
test("loads a public Panchanga day without inventing a birth minute", async () => {
let requestBody: Record<string, unknown> | null = null;
const fetchImpl: typeof fetch = async (_input, init) => {
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(JSON.stringify({
success: true,
endpoint: "panchanga_range",
report: {
calculation_policy: {
panchanga: "SwissEph Lahiri at sunrise-relative reference time",
},
days: [{
query_date: "2026-08-15",
panchanga: {
tithi: { full_name: "Shukla Tritiya", quality: "subha" },
nakshatra: { nakshatra: "Uttara Phalguni", quality: "subha" },
yoga: { yoga: "Siddha", quality: "subha" },
vara: { vara: "Saturday", quality: "asubha" },
overall_quality: "吉(Subha",
},
condition_tags: [{
key: "good_choghadiya",
label: "Has auspicious Choghadiya window",
guidance: "At least one auspicious window is available.",
}],
}],
},
}), { status: 200, headers: { "content-type": "application/json" } });
};
const context = await loadGeneralDailyPanchangaContext({
date: "2026-08-15",
reference: { latitude: 25.033, longitude: 121.5654, timezoneOffset: 8, placeLabel: "已保存地点" },
fetchImpl,
apiBase: "http://jyotish.test",
});
assert.deepEqual(requestBody, {
start_date: "2026-08-15",
end_date: "2026-08-15",
lat: 25.033,
lon: 121.5654,
tz: 8,
});
assert.deepEqual(context satisfies GeneralDailyPanchangaContext, {
scope: "public_day_no_natal_chart",
date: "2026-08-15",
referencePlace: "已保存地点",
calculationPolicy: "SwissEph Lahiri at sunrise-relative reference time",
panchanga: {
vara: "Saturday",
tithi: "Shukla Tritiya",
nakshatra: "Uttara Phalguni",
yoga: "Siddha",
overallQuality: "吉(Subha",
},
conditionTags: [{
key: "good_choghadiya",
label: "Has auspicious Choghadiya window",
guidance: "At least one auspicious window is available.",
}],
boundaries: [
"仅为所选地点与日期的公共 Panchanga 日历,不是个人命盘或个人预测。",
"未使用出生分钟、时段中点、00:00 或任何候选出生时间。",
"不得据此声称个人上升点、宫位、大运、本命过境叠加或确定事件。",
],
});
});
test("rejects an incomplete Panchanga response instead of fabricating daily evidence", async () => {
const fetchImpl: typeof fetch = async () => new Response(JSON.stringify({
success: true,
endpoint: "panchanga_range",
report: { days: [] },
}), { status: 200 });
await assert.rejects(
loadGeneralDailyPanchangaContext({ date: "2026-08-15", fetchImpl, apiBase: "http://jyotish.test" }),
/general_daily_panchanga_unavailable/,
);
});
+25 -6
View File
@@ -11,18 +11,18 @@ import {
const payloadA = {
greeting: "林遥,欢迎开始今天的咨询。",
suggestions: [
{ theme: "career", text: "林遥的事业方向是什么?" },
{ theme: "marriage", text: "林遥的关系模式是什么?" },
{ theme: "timing", text: "林遥何时适合采取行动" },
{ theme: "career", text: "请帮我梳理目前的事业方向" },
{ theme: "marriage", text: "请帮我看看关系中容易重复什么模式。" },
{ theme: "timing", text: "请帮我看看什么时候适合采取行动" },
],
} as const;
const payloadB = {
greeting: "周宁,欢迎开始今天的咨询。",
suggestions: [
{ theme: "career", text: "周宁的事业方向是什么?" },
{ theme: "marriage", text: "周宁的关系模式是什么?" },
{ theme: "timing", text: "周宁何时适合采取行动?" },
{ theme: "career", text: "请帮我看看下一步的事业重点。" },
{ theme: "marriage", text: "请帮我看看目前的关系重点。" },
{ theme: "timing", text: "请帮我看看哪些阶段适合主动推进。" },
],
} as const;
@@ -100,6 +100,25 @@ test("slow Agent generation is aborted and a terminal fallback is cached before
});
});
test("detached Agent questions are rejected in favor of user-centered fallbacks", async () => {
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
const post = createPost(repository, async () => JSON.stringify({
greeting: "欢迎开始今天的咨询,可以先选择一个主题。",
suggestions: [
{ theme: "career", text: "印度占星一般会从哪些因素理解事业方向?" },
{ theme: "marriage", text: "印度占星一般如何分析关系模式?" },
{ theme: "timing", text: "印度占星中的时间推运通常会看哪些因素?" },
],
}));
const body = await responseBody(await post());
assert.equal(body.source, "fallback");
assert.ok(body.suggestions.every((item) => /我/.test(item.text)));
assert.ok(body.suggestions.every((item) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(item.text)));
});
test("PostgreSQL Date birth_date is normalized before onboarding validation", async () => {
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
birth_date: new Date(1990, 5, 15),
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { preserveShallowEqual } from "../src/lib/preserve-shallow-equal.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
test("preserves the current profile reference when refreshed values are unchanged", () => {
const current = {
name: "测试用户",
date: "2000-01-01",
time: "",
birthTimeSource: "period_only",
birthTimePeriod: "evening",
timezoneId: "Asia/Shanghai",
latitude: 31.23,
longitude: 121.47,
timezoneOffset: null,
};
const refreshed = { ...current };
assert.notEqual(refreshed, current);
assert.equal(preserveShallowEqual(current, refreshed), current);
});
test("uses the refreshed profile reference when a value changed", () => {
const current = {
name: "测试用户",
birthTimePeriod: "evening",
timezoneId: "Asia/Shanghai",
};
const refreshed = {
...current,
birthTimePeriod: "morning",
};
assert.equal(preserveShallowEqual(current, refreshed), refreshed);
});
test("account refresh preserves an equivalent normalized profile instead of replacing it", () => {
const refreshAccountStart = pageSource.indexOf("async function refreshAccount()");
const refreshAccountSource = pageSource.slice(
refreshAccountStart,
pageSource.indexOf("function updateSession", refreshAccountStart),
);
assert.match(
refreshAccountSource,
/setProfile\(\(current\) => preserveShallowEqual\(current, nextProfile\)\)/,
);
assert.doesNotMatch(refreshAccountSource, /setProfile\(nextProfile\)/);
});
@@ -116,7 +116,10 @@ test("candidate acceptance refreshes the profile result without overwriting an o
page.indexOf("function updateSession"),
);
assert.match(refresh, /const nextProfile = readProfile\(latest\.profile\)/);
assert.match(refresh, /setProfile\(nextProfile\)/);
assert.match(
refresh,
/setProfile\(\(current\) => preserveShallowEqual\(current, nextProfile\)\)/,
);
assert.doesNotMatch(refresh, /setProfileDraft/);
});
@@ -123,11 +123,153 @@ test("loadV9RectificationProfile rejects incomplete profiles without creating a
}
});
test("homepage and new fail closed when a fresh profile has no reported time", async () => {
test("period-only profiles open with their selected server-owned range", async () => {
const cases = [
{ intent: "homepage", source: "period_only", period: "morning" },
{ intent: "new", source: "unknown", period: null },
{ intent: "homepage", source: "legacy_import", period: "evening" },
{ intent: "homepage", period: "morning", range: { start_time: "08:00", end_time: "11:59" } },
{ intent: "new", period: "late_night", range: { start_time: "23:00", end_time: "03:59" } },
] as const;
for (const item of cases) {
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
const accounting = fakeAccounting({
profile: {
...completeProfile,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: item.period,
},
rpc: async (_fn, args) => {
capturedArgs.value = args;
return {
data: {
disposition: "created",
case_id: caseId,
session_id: sessionId,
status: "draft",
should_start_opening: true,
skill_version: "9.0.0",
},
error: null,
};
},
});
const response = await openRectificationCase(accounting, "user-1", {
intent: item.intent,
requestId,
});
assert.equal(response.disposition, "created");
assert.deepEqual(capturedArgs.value?.p_candidate_range, item.range);
assert.equal(
(capturedArgs.value?.p_baseline_birth_snapshot as Record<string, unknown>).reported_birth_time,
null,
);
}
});
test("period-only profiles with an IANA timezone can open when the cached offset is missing", async () => {
const originalFetch = globalThis.fetch;
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
let timezoneLookupCalled = false;
globalThis.fetch = async (input, init) => {
timezoneLookupCalled = true;
assert.equal(String(input), "http://127.0.0.1:5200/api/location/timezone");
assert.deepEqual(JSON.parse(String(init?.body)), {
latitude: 36.420487,
longitude: 114.209936,
birthDate: "1997-08-08",
birthTime: "20:30",
});
return new Response(JSON.stringify({
available: true,
timezoneId: "Asia/Shanghai",
timezoneOffset: 8,
}), { status: 200 });
};
try {
const accounting = fakeAccounting({
profile: {
...completeProfile,
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "evening",
timezone_offset: null,
},
rpc: async (_fn, args) => {
capturedArgs.value = args;
return {
data: {
disposition: "created",
case_id: caseId,
session_id: sessionId,
status: "draft",
should_start_opening: true,
skill_version: "9.0.0",
},
error: null,
};
},
});
const response = await openRectificationCase(accounting, "user-1", {
intent: "homepage",
requestId,
});
assert.equal(response.disposition, "created");
assert.equal(timezoneLookupCalled, true);
assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "18:00", end_time: "22:59" });
assert.equal(
(capturedArgs.value?.p_baseline_birth_snapshot as Record<string, unknown>).timezone_offset,
8,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("unknown-time profiles open with a full-day server-owned range", async () => {
const capturedArgs: { value: Record<string, unknown> | null } = { value: null };
const accounting = fakeAccounting({
profile: {
...completeProfile,
reported_birth_time: null,
birth_time_source: "unknown",
birth_time_period: null,
},
rpc: async (_fn, args) => {
capturedArgs.value = args;
return {
data: {
disposition: "created",
case_id: caseId,
session_id: sessionId,
status: "draft",
should_start_opening: true,
skill_version: "9.0.0",
},
error: null,
};
},
});
const response = await openRectificationCase(accounting, "user-1", {
intent: "homepage",
requestId,
});
assert.equal(response.disposition, "created");
assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "00:00", end_time: "23:59" });
});
test("fresh profiles still fail closed when their birth-time declaration is incomplete", async () => {
const cases = [
{ source: "period_only", period: null },
{ source: "period_only", period: "not_a_period" },
{ source: "family_exact", period: null },
{ source: "approximate", period: null },
] as const;
for (const item of cases) {
@@ -146,7 +288,7 @@ test("homepage and new fail closed when a fresh profile has no reported time", a
});
await assert.rejects(
() => openRectificationCase(accounting, "user-1", { intent: item.intent, requestId }),
() => openRectificationCase(accounting, "user-1", { intent: "homepage", requestId }),
(error: unknown) =>
error instanceof RectificationCaseServiceError && error.code === "profile_incomplete",
);
@@ -46,6 +46,10 @@ const giteaProductionMigrationWorkflow = new URL(
"../../.gitea/workflows/migrate-production-database.yml",
import.meta.url,
);
const giteaProductionRecoveryWorkflow = new URL(
"../../.gitea/workflows/create-production-recovery.yml",
import.meta.url,
);
const giteaReleaseQualityWorkflow = new URL(
"../../.gitea/workflows/release-quality-gate.yml",
import.meta.url,
@@ -74,6 +78,10 @@ const productionMigrationScript = new URL(
"../../deploy/run-production-migration.sh",
import.meta.url,
);
const productionRecoveryScript = new URL(
"../../deploy/run-production-recovery.sh",
import.meta.url,
);
const productionSyncScript = new URL(
"../../deploy/sync-production-tree.sh",
import.meta.url,
@@ -1053,6 +1061,53 @@ test("production runner validates state and migrations before switching exact im
});
test("production recovery workflow creates a verified encrypted off-site artifact", () => {
const workflow = read(giteaProductionRecoveryWorkflow);
const runner = read(productionRecoveryScript);
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /permissions:\n\s+contents: read\n\s+actions: write/);
assert.match(workflow, /group: production-mutation/);
assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /git checkout --detach --force "\$DEPLOY_SHA"/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /ServerAliveInterval=15.*ServerAliveCountMax=4/);
assert.match(workflow, /git show "\$DEPLOY_SHA:deploy\/run-production-recovery\.sh"/);
assert.match(workflow, /production-recovery-\$GITHUB_RUN_ID/);
assert.match(workflow, /INPUT_RETENTION-DAYS.*30/);
assert.match(workflow, /INPUT_COMPRESSION-LEVEL.*0/);
assert.match(workflow, /sha256sum --check --status/);
assert.match(workflow, /restore_database_removed == true/);
assert.doesNotMatch(workflow, /STAGING_BACKUP_ENCRYPTION_KEY|\.env\.production\.database[^\n]*(?:cat|awk)/);
assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/);
assert.match(runner, /for directory in "\$state_directory" "\$backup_directory"/);
assert.doesNotMatch(runner, /sudo -n chown/);
assert.match(runner, /ownership_image=.*docker inspect --format '\{\{\.Image\}\}'/);
assert.match(runner, /sudo -n docker run --rm --pull never --network none --read-only --user 0:0/);
assert.match(runner, /--cap-drop ALL --cap-add CHOWN --security-opt no-new-privileges/);
assert.match(runner, /--entrypoint chown "\$ownership_image"/);
assert.match(runner, /ownership_lock_target="\/mutation\.lock"[\s\S]*--mount "type=bind,src=\$lock_file,dst=\$ownership_lock_target"/);
assert.match(runner, /ownership_targets=\(\)[\s\S]*ownership_targets\+=\("\$ownership_lock_target"\)[\s\S]*ownership_targets\+=\("\$state_directory" "\$backup_directory"\)/);
assert.match(runner, /"\$deployment_uid:\$deployment_gid" "\$\{ownership_targets\[@\]\}"/);
assert.match(runner, /production mutation lock is unsafe/);
assert.doesNotMatch(runner, /rm -f[^\n]*mutation\.lock|chown -R/);
assert.match(runner, /another production mutation holds the host lock/);
assert.match(runner, /usage_percent < 70/);
assert.match(runner, /pg_dump[\s\S]*--format=custom --no-owner --no-acl/);
assert.match(runner, /openssl enc -aes-256-cbc -salt -pbkdf2/);
assert.match(runner, /openssl enc -d -aes-256-cbc -pbkdf2/);
assert.match(runner, /pg_restore[\s\S]*--no-owner --no-acl --exit-on-error/);
assert.match(runner, /DROP DATABASE IF EXISTS[\s\S]*WITH \(FORCE\)/);
assert.match(runner, /restore_database_removed/);
assert.match(runner, /gitea-actions-run-\$\{RECOVERY_RUN_ID\}/);
assert.doesNotMatch(runner, /docker compose down|down -v|dropdb jyotisha|rm -rf[^\n]*backup_directory/);
});
test("Gitea production schema migration is exact-SHA gated and isolated from ETL and deploy", () => {
const workflow = read(giteaProductionMigrationWorkflow);
const runner = read(productionMigrationScript);
+15 -14
View File
@@ -71,19 +71,19 @@ test("default starter questions derive every canonical domain with evidence and
assert.match(domainById.get("timing")?.requiredLayers.join(" ") ?? "", /negative holdout gate/);
});
test("profiles without a usable birth minute receive all canonical general-knowledge prompts", () => {
test("profiles without a usable birth minute receive user-centered starter prompts", () => {
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]);
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.prompt), [
"印度占星一般会从哪些因素理解事业方向?",
"印度占星一般如何分析关系模式",
"印度占星一般如何分析财富结构与风险?",
"印度占星如何在非医疗诊断边界内理解身心压力?",
"印度占星一般如何理解学习方式与进阶节奏?",
"印度占星一般如何分析迁居、置业海外发展",
"印度占星一般如何理解家庭关系与责任模式?",
"印度占星中的年运分析通常包含哪些证据层?",
"印度占星中的时间推运通常会看哪些因素?",
"印度占星综合咨询会如何划分不同主题与证据边界?",
"请帮我梳理目前的事业方向和下一步重点。",
"请帮我看看我在关系中容易重复什么模式",
"请帮我分析目前的财富重点、风险和更稳妥的选择。",
"请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。",
"请帮我看看我更适合怎样学习,以及如何安排下一步。",
"请帮我分析现阶段是否适合迁居、置业或考虑海外发展",
"请帮我看看家庭关系中最需要处理的问题和责任边界。",
"请帮我看看未来一年的主要趋势、机会和需要留意的阶段。",
"请帮我看看目前适合推进什么,哪些事情需要再等等。",
"请结合我的情况,帮我找出现在最值得优先处理的三个问题。",
]);
for (const topic of generalGuidedJyotishTopics) {
const personalTopic = defaultGuidedJyotishTopics.find((candidate) => candidate.id === topic.id);
@@ -95,10 +95,11 @@ test("profiles without a usable birth minute receive all canonical general-knowl
assert.equal(topic.claimBoundary, personalTopic.claimBoundary);
assert.notEqual(topic.prompt, personalTopic.prompt);
}
assert.ok(generalGuidedJyotishTopics.every((topic) => !/我的|我近期|未来一年,事业和收入/.test(topic.prompt)));
assert.ok(generalGuidedJyotishTopics.every((topic) => /我/.test(topic.prompt)));
assert.ok(generalGuidedJyotishTopics.every((topic) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(topic.prompt)));
assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/);
assert.doesNotMatch(pageSource, /回答一般占星知识/);
assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/);
assert.match(pageSource, /出生时间不足以支持的部分,我会明确说明,不会补造具体分钟/);
assert.match(pageSource, /请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么/);
assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
});