Compare commits

...
Author SHA1 Message Date
Jesse_ChenandClaude Opus 5.5 9274491681 docs(ops): prove the recovery backup decrypts before dropping production (issue #74)
The rollback restore dropped jyotisha before decrypting the backup; a bad
key or damaged file would leave the site on an empty database. Load the
host env file, check sha256, decrypt into pg_restore --list first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 21:28:47 +08:00
jesse-uxandGrok (agent loop) 40bc565a49 docs(testing): keep the host rehearsal sheet and record the local PostgreSQL 17 drill
Independent Staging Quality Gate / validate (push) Successful in 18m13s
Independent Staging Quality Gate / publish (push) Successful in 4m37s
Co-Authored-By: Grok (agent loop)
2026-10-14 21:02:19 +08:00
jesse-uxandGrok (agent loop) 09906cdccc fix(release): keep owners and grants in production recovery points
Co-Authored-By: Grok (agent loop)
2026-10-14 21:01:52 +08:00
jesse-uxandGrok (agent loop) d13250f40a merge origin/staging into the release-path branch
Bring in the second review brief for issue 74. Keep the existing progress links and add the fix2 task row.

Co-Authored-By: Grok (agent loop)
2026-10-14 20:28:40 +08:00
Jesse_ChenandClaude Opus 5.5 6b12e09436 docs(tasks): release path fix brief 2, recovery points keep owners and grants (agent loop, issue #74)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 20:08:16 +08:00
jesse-uxandGrok (agent loop) 32aa46b573 fix(release): close the first review of the production release path
Host rehearsal no longer needs Node on the production machine, and a failed cleanup now stops with the rehearsal database name. Docs-only staging heads no longer block rollback. Tests no longer hardcode Windows paths, and a trailing slash on the production site still refuses the test code.

pytest 12 passed; tsc exit 0; lint 0 errors; identity tests 9 passed, 0 cancelled. test:db was not run.

Co-Authored-By: Grok (agent loop)
2026-10-14 16:37:58 +08:00
jesse-uxandGrok (agent loop) 0dc1d8fa52 merge origin/staging into the release-path fix branch
Co-Authored-By: Grok (agent loop)
2026-10-14 15:54:45 +08:00
Jesse_ChenandClaude Opus 5.5 53b2f0008d docs(tasks): release path production fix brief (agent loop, issue #74, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 15:39:22 +08:00
Claude 3e20c125b8 test(api): capability audit no longer counts the deleted dead panel as app-visible (BUG-1342)
Independent Staging Quality Gate / validate (push) Successful in 18m9s
Independent Staging Quality Gate / publish (push) Successful in 3m56s
The audit scans frontend app/components source. Its only Case Validation
hit was evidence-audit-panel.tsx, never mounted and deleted in #63, so the
topic is truthfully gone and case_validator's UX level is thin. Product
owner chose option A on issue #63 (Claude fixes directly). Staging gate
run 1824 was red on this test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 15:27:53 +08:00
jesse-uxandGrok (agent loop) 29949e0a29 接受删除旧报告页,并跟上最新 staging
Independent Staging Quality Gate / validate (push) Failing after 8m31s
Independent Staging Quality Gate / publish (push) Skipped
旧的结构化报告页已由另一单删除,标识留在现在用的阅读页。测试不再读取那个文件。更新日志和 Bug 历史两边都保留。

自测:tsc 退出码 0;lint 0 error、123 warning;修复单点名的 3 个测试 7 通过、0 失败、0 取消。

Co-Authored-By: Grok (agent loop)
2026-10-14 12:27:33 +08:00
Jesse_ChenandClaude Opus 5.5 7290477ead docs(tasks): beta main path fix brief (agent loop, issue #73, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 12:15:10 +08:00
Claude 400af57e86 merge origin/staging into frontend dead code (issue #63)
Independent Staging Quality Gate / validate (push) Failing after 7m39s
Independent Staging Quality Gate / publish (push) Skipped
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 11:20:59 +08:00
Claude 4130d1dba9 merge origin/staging into consult prep parallel (issue #62)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m43s
Docs-only catch-up; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 11:14:51 +08:00
jesse-uxandGrok (agent loop) 9e29310941 合并 staging,并让发问准备的检查跟上新位置
两条按源码原文查找的检查改认搬走后的写法,意思没有放宽。对照脚本挪到测试目录,镜像构建不再去找测试文件。业务代码和三个开关的默认值没改。

自测:相关前端测试 40 通过,失败 0,取消 0。假时钟仍是并行 160、串行 210。tsc 0 错,lint 0 error。

Co-Authored-By: Grok (agent loop)
2026-10-14 10:56:17 +08:00
jesse-uxandGrok (agent loop) 00e6efdbfa 发布可以跟上只多了文档的测试环境头
四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。

自测:发布修订 7 通过,演练安全 3 通过,tsc 0 错,lint 0 error。本机空库演练在服务角色读人物表处按设计停止(退出码 4,其他人物已确认行数为 0)。

Co-Authored-By: Grok (agent loop)
2026-10-14 10:24:40 +08:00
jesse-uxandGrok (agent loop) fca62fda43 给别人出报告能读到档案,对话和报告标明 AI 生成
BUG-1063:后台按列只读档案,授权前数据库拒绝读表,授权后通过。对话输入框下和报告标题下加上 AI 生成说明。状态板按已部署提交改措辞,不标 resolved。下载文件元数据没有做。

自测:tsc 退出码 0;lint 0 error;定向测试 51 通过;test:db 82 项里 13 项为 Windows 已知失败,无新增;隐私标记退出码 0。

Co-Authored-By: Grok (agent loop)
2026-10-14 09:30:51 +08:00
jesse-uxandGrok (agent loop) a0e223e20c 发问准备可以并行,三个开关先保持关闭(BUG-1337~1339)
开流前的注销检查可以和读会话、解析模型同时进行;审核改到扣点前等待,不再挡住第一行;模型版本可以短缓存;两个领域可以一起算。本机没有数据库中位数,也没有带星盘网络库的镜像,所以默认仍是关闭、缓存 0、领域逐个算。

自测:tsc 0 错;lint 0 error;相关测试失败 0、取消 0。test:db 未跑。

Co-Authored-By: Grok (agent loop)
2026-10-14 07:23:58 +08:00
66 changed files with 40035 additions and 18381 deletions
+10 -30
View File
@@ -40,43 +40,23 @@ jobs:
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
}
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 "$GITEA_SHA"
git checkout --detach --force "$GITEA_SHA"
REQUESTED_SHA="$DEPLOY_SHA" CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
[[ "$release_sha" == "$DEPLOY_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch recovery from current main" >&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
+79 -68
View File
@@ -66,79 +66,77 @@ jobs:
allow_rollback=true
fi
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=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&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 \
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 "$GITEA_SHA"
git checkout --detach --force "$GITEA_SHA"
if [[ "$allow_rollback" == false ]]; then
CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
gate_run_id="$(awk -F= '$1 == "gate_run_id" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
[[ "$release_sha" == "$REQUESTED_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; }
controller_sha="$release_sha"
controller_gate_run_id="$gate_run_id"
else
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/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)"
controller_sha="$(read_ref_sha main)"
[[ "$controller_sha" == "$staging_head" ]] || { echo "main and staging must identify the same reviewed release" >&2; exit 1; }
if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then
echo "stale production revision refused; use explicit manual rollback only when intended" >&2
exit 1
fi
if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$controller_sha" ]]; then
comparison="$(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/compare/$REQUESTED_SHA...$controller_sha")"
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
(.commits // []) as $commits |
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
def reaches($sha; $seen):
if $sha == $base then true
elif ($seen | index($sha)) != null then false
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
(.total_commits | type) == "number" and
.total_commits == ($commits | length) and ($commits | length) > 0 and
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
fi
controller_gate_run_id="$gate_run_id"
if [[ "$controller_sha" != "$REQUESTED_SHA" ]]; then
controller_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=$controller_sha&branch=staging&event=push&status=success&limit=100")"
controller_run="$(jq -cer --arg sha "$controller_sha" '
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$controller_runs")"
controller_gate_run_id="$(jq -er '.id' <<<"$controller_run")"
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&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)"
controller_sha="$(read_ref_sha main)"
[[ "$controller_sha" == "$staging_head" ]] || { echo "main and staging must identify the same reviewed release" >&2; exit 1; }
if [[ "$REQUESTED_SHA" != "$controller_sha" ]]; then
comparison="$(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/compare/$REQUESTED_SHA...$controller_sha")"
jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" '
(.commits // []) as $commits |
def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha];
def reaches($sha; $seen):
if $sha == $base then true
elif ($seen | index($sha)) != null then false
else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end;
(.total_commits | type) == "number" and
.total_commits == ($commits | length) and ($commits | length) > 0 and
([$commits[].sha] | length == (unique | length)) and reaches($head; [])
' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; }
fi
discover_output="$(RESOLVE_MODE=discover CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh)"
controller_sha="$(awk -F= '$1 == "release_sha" { print $2 }' <<<"$discover_output" | tail -n 1)"
controller_gate_run_id="$(awk -F= '$1 == "gate_run_id" { print $2 }' <<<"$discover_output" | tail -n 1)"
[[ "$controller_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "no docs-only staging quality gate can be released" >&2; exit 1; }
[[ "$controller_gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&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=$REQUESTED_SHA&event=workflow_dispatch&status=success&limit=100")"
jq -e --arg sha "$REQUESTED_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; }
fi
[[ "$controller_gate_run_id" =~ ^[0-9]+$ ]]
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=$REQUESTED_SHA&event=workflow_dispatch&status=success&limit=100")"
jq -e --arg sha "$REQUESTED_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; }
if [[ "$allow_rollback" == false ]]; then
observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \
@@ -338,8 +336,14 @@ jobs:
jq -er 'select(type == "array" and length == 1) | .[0] |
select(.ref == "refs/heads/main") | .object.sha |
select(test("^[0-9a-f]{40}$"))')"
[[ "$current_main" == "$DEPLOY_SHA" && "$current_staging" == "$DEPLOY_SHA" ]] || {
echo "main or staging advanced during deployment; refusing stale mutation" >&2
[[ "$current_main" == "$current_staging" ]] || {
echo "main and staging diverged during deployment; refusing stale mutation" >&2
exit 1
}
checker=artifacts/controller/extracted/deploy/is-docs-only-range.sh
[[ -f "$checker" ]] || { echo "gate-attested controller bundle lacks deploy/is-docs-only-range.sh" >&2; exit 1; }
bash "$checker" --api "$DEPLOY_SHA" "$current_staging" || {
echo "staging advanced past the release SHA with a gated change; refusing stale mutation" >&2
exit 1
}
}
@@ -376,6 +380,13 @@ jobs:
forward_verified=true
fi
require_current_release_heads
if [[ "$ALLOW_ROLLBACK" == true ]]; then
recovery_format="$(ssh "${ssh_options[@]}" "$remote" "awk -F= '\$1 == \"RECOVERY_FORMAT\" { print \$2 }' '$DEPLOY_PATH/.state/recovery-baseline.env' || true")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
fi
printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin"
ssh "${ssh_options[@]}" "$remote" "sudo -n env INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' PRODUCTION_URL='$PRODUCTION_URL' PRODUCTION_ADMIN_URL='$PRODUCTION_ADMIN_URL' VERIFICATION_MODE='$VERIFICATION_MODE' bash '$incoming/deploy/run-production-deploy.sh'"
require_current_release_heads
@@ -85,54 +85,18 @@ jobs:
PY
echo "Recovery attested: reference=$RECOVERY_REFERENCE created_at=$RECOVERY_CREATED_AT restore_verified=true"
echo "WARNING: migration files run sequentially and are not atomic as a whole; recovery may be required after a partial migration." >&2
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 migration requires main and staging to equal deploy_sha" >&2
exit 1
}
[[ "$GITEA_SHA" == "$DEPLOY_SHA" ]] || {
echo "dispatch the production migration workflow from the exact main release SHA" >&2
exit 1
}
runs="$(curl --fail --silent --show-error \
--header "Authorization: token $GITEA_TOKEN" \
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")"
selected_run="$(jq -cer --arg sha "$DEPLOY_SHA" '
[.workflow_runs[] | select(
(.path | split("@")[0] | endswith("backend-quality-gate.yml")) and
.head_sha == $sha and .head_branch == "staging" and
.event == "push" and .conclusion == "success"
)] | sort_by(.id) | reverse | first
' <<<"$runs")"
gate_run_id="$(jq -er '.id' <<<"$selected_run")"
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || {
echo "no successful exact-SHA staging backend quality gate found" >&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
}
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 "$GITEA_SHA"
git checkout --detach --force "$GITEA_SHA"
REQUESTED_SHA="$DEPLOY_SHA" CHECK_RELEASE_GATE=true bash deploy/resolve-release-revision.sh >>"$GITHUB_OUTPUT"
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
gate_run_id="$(awk -F= '$1 == "gate_run_id" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$GITHUB_OUTPUT" | tail -n 1)"
[[ "$release_sha" == "$DEPLOY_SHA" ]] || { echo "requested SHA is not the docs-only release revision" >&2; exit 1; }
[[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging backend quality gate found" >&2; exit 1; }
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch the production migration workflow from current main" >&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" ]] || {
@@ -317,8 +281,14 @@ jobs:
jq -er 'select(type == "array" and length == 1) | .[0] |
select(.ref == "refs/heads/main") | .object.sha |
select(test("^[0-9a-f]{40}$"))')"
[[ "$current_main" == "$DEPLOY_SHA" && "$current_staging" == "$DEPLOY_SHA" ]] || {
echo "main or staging advanced during production migration; refusing stale mutation" >&2
[[ "$current_main" == "$current_staging" ]] || {
echo "main and staging diverged during production migration; refusing stale mutation" >&2
exit 1
}
checker=artifacts/staging-image/extracted/deploy/is-docs-only-range.sh
[[ -f "$checker" ]] || { echo "gate-attested controller bundle lacks deploy/is-docs-only-range.sh" >&2; exit 1; }
bash "$checker" --api "$DEPLOY_SHA" "$current_staging" || {
echo "staging advanced past the release SHA with a gated change; refusing stale mutation" >&2
exit 1
}
}
@@ -356,6 +326,11 @@ jobs:
fi
require_current_release_heads
printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin"
recovery_format="$(ssh "${ssh_options[@]}" "$remote" "awk -F= '\$1 == \"RECOVERY_FORMAT\" { print \$2 }' '$DEPLOY_PATH/.state/recovery-baseline.env' || true")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
ssh "${ssh_options[@]}" "$remote" "sudo -n env INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' FORWARD_REVISION_VERIFIED='$forward_verified' RECOVERY_REFERENCE='$RECOVERY_REFERENCE' RECOVERY_CREATED_AT='$RECOVERY_CREATED_AT' RESTORE_VERIFIED='$RESTORE_VERIFIED' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' bash '$incoming/deploy/run-production-migration.sh'"
require_current_release_heads
+18
View File
@@ -20,6 +20,24 @@ jobs:
git remote add origin https://git.copse.top/root/Jyotisha.git
git fetch --no-tags origin "$GITEA_SHA"
git checkout --detach --force "$GITEA_SHA"
- name: Record whether main is docs-only ahead of the last staging gate
env:
GITEA_API_URL: ${{ gitea.api_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RESOLVE_MODE: discover
run: |
set -euo pipefail
bash deploy/resolve-release-revision.sh | tee "$RUNNER_TEMP/release-revision.env"
release_sha="$(awk -F= '$1 == "release_sha" { print $2 }' "$RUNNER_TEMP/release-revision.env")"
head_sha="$(awk -F= '$1 == "head_sha" { print $2 }' "$RUNNER_TEMP/release-revision.env")"
[[ "$GITEA_SHA" == "$head_sha" ]] || { echo "dispatch the release gate from current main" >&2; exit 1; }
{
echo "### Release revision"
echo "- head_sha: ${head_sha}"
echo "- release_sha: ${release_sha}"
echo "- H relative to R is docs-only (is-docs-only-range.sh exited 0)"
} >>"$GITHUB_STEP_SUMMARY"
- name: Prepare pinned Node tooling
run: |
set -euo pipefail
+3 -3
View File
@@ -45,10 +45,10 @@
1. 动手前 `git fetch origin --prune`,以远端 **`origin/staging`** 为基线。本地 `staging` / `main` 经常落后远端上百个提交,不得作为基线。
2. 在独立 worktree 开发:路径 `.worktrees/<主题>-<日期>`,分支 `codex/<主题>-<日期>`(见 §3)。
3. 交付到 staging 用快进推送 `git push origin HEAD:staging`。这会触发 Gitea `backend-quality-gate`;它的 `paths:` 过滤与 `deploy/gated-paths.txt` 逐行一致。改动**全部**落在清单之外的纯文档推送(`docs/**`、根目录 `BLOCKED.md` / `CHANGELOG*.md` / `CONTEXT.md` / `AGENTS.md` / `CLAUDE.md` / `README.md` 等)不触发门禁、不发布镜像、不部署。鼓励把文档与同批代码合并一次推送,避免 staging head 与已部署 SHA 分离。
4. 门禁构建 digest 固定镜像并 dispatch `deploy-staging`;随后在 staging 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于**最近一次含门禁路径改动的 staging 提交**;其后若只有纯文档提交,`deploy/is-docs-only-range.sh <该 SHA> <staging head>` 必须退出 0,否则视为未部署。
5. 提升到 `main` **必须快进,不得 merge**。`deploy-production.yml` 强制 `main` 与 `staging` 指向同一 SHA,merge commit 会让生产部署失败。
4. 门禁构建 digest 固定镜像并 dispatch `deploy-staging`;随后在 staging 完成与风险相称的验收。`GET /api/health` 的 `.deployment.gitCommit` 必须等于**最近一次含门禁路径改动的 staging 提交**。记这个提交为发布修订 `R`,记当前 staging head 为 `H`。其后若只有纯文档提交,`deploy/is-docs-only-range.sh R H` 必须退出 0,否则视为未部署。生产四个手动工作流发布的是 `R`,不是纯文档的 `H`;公网 health 的 gitCommit 仍必须等于 `R`。该脚本退出 1 或 2 都拒绝发布。
5. 提升到 `main` **必须快进到 `H`,不得 merge**。`main` 与 `staging` 必须是同一 SHA。生产镜像和控制器产物取 `R` 的门禁结果,不取纯文档的 `H`。merge commit 会让生产部署失败。
6. 推送后必须核对远端 SHA;远端验证失败时不得声称已交付。
7. 不得自行提升 `main`、不改 `.gitea/workflows/**`、不动 DNS——这三件事只由产品负责人触发。
7. 不得自行提升 `main`、不动 DNS——这两件事只由产品负责人触发。不改 `.gitea/workflows/**`,除非任务书的决策记录点名了具体文件。2026-10-14 的授权只覆盖 `deploy-production.yml`、`create-production-recovery.yml`、`migrate-production-database.yml`、`release-quality-gate.yml` 的发布修订规则(`R` 加其后纯文档)。不改 staging 工作流,不改 `deploy/gated-paths.txt`。
## 3. 工作树与多会话
+7
View File
@@ -1,5 +1,12 @@
# BLOCKED
## TASK-consult-prep-parallel-20261013:审核真实延迟、Postgres 中位数、多领域镜像(2026-10-14)
- **阿里云审核**:本机没有审核密钥。1500 毫秒挡首帧的结论来自测试里的假等待,不是真实供应商。开关 `CONSULT_PREP_PARALLEL` 保持关闭。
- **本机 Postgres**:没有量 `getUser`、注销检查、读会话、按版本读模型各 30 次的中位数。因此不能判断首帧是否真的快了两成,也不能判断模型准备是否快了一半。两个相关开关保持关闭(并行关、版本缓存 0 毫秒)。
- **多领域并发**:没有拿带星盘网络库的镜像重跑。任务书里的起草试算大约只快一成,不到两成五的开启线。`CONSULT_DOMAIN_CONCURRENCY` 保持 1。
- **staging 前后对比**:要等这支分支部署之后才能看真实首行时间。本轮不改默认值。
## TASK-rectification-explicit-entry-20261012:T0 日志、T5 模型(BUG-1306,2026-10-12)
- **T0**:本 worktree 没有 staging 主机,读不到 web 容器 `docker logs`,也没有数据库。无法核对 10-11 那一轮是否调用了 `offer-birth-time-rectification`。不阻塞实现。产品可照做的查法写在 `docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md`,只看工具名、status,以及该会话 messages 里有没有 `rectificationOffer`。
+9
View File
@@ -1,5 +1,14 @@
# 印度占星 Skill 更新日志
## 2026-10-14 — 生产可以在测试环境后面只多了文档时照常发布(待合入 staging)
- 生产发布用最近一次测过的代码版本。测试环境后面如果只多了文档,不再挡住发布。生产环境拒绝测试用验证码,网址末尾多一个斜杠也拒绝。迁移演练在主机上不再依赖本机 Node;演练库删不掉会明确失败。只多了文档时,回退不再被拒绝。Skill 版本不变(BUG-1348、BUG-1349)。
## 2026-10-14 — 给别人出报告能读到档案;对话和报告标明 AI 生成(待合入 staging)
- 给人物档案里的其他人出报告时,后台只能按出报告要用的那些列读取,不能改档案(BUG-1063)。
- 对话输入框下方,以及报告标题下,标明由 AI 生成、仅供参考。报告结尾原有的免责还在。Skill 版本不变(BUG-1347)。
## 2026-10-14 — 删掉三个没人调用的接口和约 3,000 行旧代码(待合入 staging)
- 删掉三个没有调用方的接口,其中一个不用登录就会触发大约 20 秒的计算。同时清掉大约 3,000 行已经没有页面使用的旧代码。Skill 版本不 bump(BUG-1342)。
+3 -3
View File
@@ -140,7 +140,7 @@ It fails while `frontend/src/lib/legal-entity.ts` still holds placeholder operat
## Manual production deployment with Gitea Actions
GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. The workflow requires an exact SHA shared by `main` and `staging`, an exact-SHA staging push gate and image manifest, the manual release gate, and matching public staging health. It deploys immutable registry digests and never builds application images on the production host.
GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. `main` must be a fast-forward to the current staging head `H`. The deployed SHA is `R`, the latest successful staging push of `backend-quality-gate`, and `deploy/is-docs-only-range.sh R H` must exit 0 (exit 1 or 2 both refuse). Controller artifacts and image digests come from `R`. Public staging health must report `R`. The manual release gate may record `H` when `H` is docs-only ahead of `R`. Rollback still requires an explicit confirmation, a gate run, and ancestry inside reviewed `main` history. The workflow deploys immutable registry digests and never builds application images on the production host.
The `internal` verification mode is for pre-DNS checks on the new host. The `public` mode is for the same SHA after authoritative DNS and Caddy TLS converge. Neither mode imports data, applies pending migrations, or changes DNS.
@@ -281,13 +281,13 @@ Use this order for every staging revision:
5. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2.
6. After health verification, create the local encrypted backup described below.
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
**Deploy Production** and **Migrate Production Database** stay fully manual. They still require a hand-filled 40-character SHA. For a forward release that SHA is `R` (the latest gated staging push that is docs-only behind the shared `main`/`staging` head), plus `allow_rollback` / recovery attestation (`restore_verified` is a person's guarantee that the recovery point restores). Do not copy the staging automatic migrate-then-deploy shortcut onto production.
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 still exits before app changes when a migration is pending. After the automatic pre-deploy migrate that should be rare. A failed automatic migration fails the quality gate and does not 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.
Use Gitea Actions → **Create Production Recovery Point** before every production schema migration. Dispatch it from current `main` (the staging head `H`) and set `deploy_sha` to `R`. `main` and `staging` must be the same SHA, `R` must be a docs-only ancestor of that SHA, public staging health must report `R`, and the release gate must have succeeded for `R` or for that same head. It uses the pinned production SSH identity, shares the `production-mutation` lock, and runs `deploy/run-production-recovery.sh` with shell tracing disabled. The migration rehearsal has no Gitea button; the product runs `deploy/rehearse-production-migrations.sh` from the runbook.
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.
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env bash
# Rehearse pending production migrations on a disposable database.
# The product runs this on the production host, or locally with
# JYOTISHA_REHEARSAL_LOCAL=1. There is no fifth Gitea workflow for it.
# Never restores into or migrates the database named jyotisha.
#
# Host mode runs every SQL statement inside the postgres container.
# It does not call node on the host. Restore keeps the owners and grants
# stored in the backup. Migrations then run as schema_owner.
# Exit 1: refused, including when a migration removed a table or column
# grant that was present on the restored copy.
# Exit 4: service_role still lacks column SELECT on public.chart_profiles
# for the columns named by BUG-1063. Do not migrate production.
# Exit 75: another production mutation holds the host lock.
# Exit 0 prints rehearsal_ok=1 only after those checks, and only when the
# rehearsal database was removed. A failed drop is non-zero and names
# that database so it can be dropped by hand.
set -euo pipefail
set +x
umask 077
export LC_ALL=C
STOP_BEFORE="20260925030000_chart_subject_trust_and_limit.sql"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
frontend_dir="$repo_root/frontend"
refuse() {
echo "$1" >&2
exit 1
}
database_name_from_url() {
local rest="${1#postgresql://}"
rest="${rest#*@}"
local path="${rest#*/}"
case "$path" in
*\?*)
refuse "rehearsal URL must not include query parameters"
;;
esac
printf '%s' "$path"
}
docker_cli() {
if [ "${JYOTISHA_REHEARSAL_FAKE_HOST:-}" = 1 ]; then
docker "$@"
else
sudo -n docker "$@"
fi
}
assert_rehearse_name() {
local name="$1"
[[ "$name" != "jyotisha" ]] || refuse "refusing to touch the production database"
[[ "$name" =~ ^rehearse_[0-9a-z_]+$ ]] || refuse "rehearsal database name must match rehearse_<suffix>"
}
copy_batch() {
local dest="$1" mode="$2" dir file base
local -a selected=()
mkdir -p "$dest"
for dir in "$frontend_dir/db/migrations" "$frontend_dir/supabase/migrations"; do
[ -d "$dir" ] || continue
selected=()
for file in "$dir"/*.sql; do
[ -f "$file" ] || continue
base="${file##*/}"
if [ "$mode" = before ] && [[ ! "$base" < "$STOP_BEFORE" ]]; then
continue
fi
selected+=("$file")
done
if [ "${#selected[@]}" -gt 0 ]; then
cp -p "${selected[@]}" "$dest/"
fi
done
}
count_sql() {
find "$1" -maxdepth 1 -type f -name '*.sql' | wc -l | tr -d ' '
}
print_safe_log() {
local log="$1" line
while IFS= read -r line; do
case "$line" in
applied\ *.sql|already\ applied\ *.sql)
printf '%s\n' "$line"
;;
notice\ *)
if [[ "$line" =~ ^notice\ [A-Za-z0-9_.:=[:space:]-]+$ ]]; then
printf '%s\n' "$line"
else
echo "notice_redacted=1"
fi
;;
migration\ failed:*|migration\ file\ missing:*|migration\ checksum\ mismatch:*|duplicate\ migration\ filename:*|unable\ to\ read\ migrations\ directory|database\ migration\ failed)
printf '%s\n' "$line" >&2
;;
esac
done <"$log"
}
run_node_migrator() {
local batch="$1" log="$2" status migrate_url
migrate_url="${SCHEMA_DATABASE_URL}?options=-c%20role%3Dschema_owner"
set +e
(
cd "$frontend_dir"
SCHEMA_DATABASE_URL="$migrate_url" MIGRATIONS_DIRECTORY="$batch" node scripts/db-migrate.mjs
) >"$log" 2>&1
status=$?
set -e
print_safe_log "$log"
[ "$status" -eq 0 ] || refuse "rehearsal migration failed"
}
run_node_sql() {
local kind="$1"
(
cd "$frontend_dir"
node --input-type=module - "$kind" <<'PY'
import pg from "pg";
const kind = process.argv[2];
const source = process.env.SCHEMA_DATABASE_URL;
if (!source || !source.startsWith("postgresql://")) process.exit(2);
const url = new URL(source);
const name = url.pathname.replace(/^\//, "").split("/")[0];
if (!/^rehearse_[0-9a-z_]+$/.test(name) || name === "jyotisha") process.exit(2);
const client = new pg.Client({ connectionString: source });
await client.connect();
if (kind === "count") {
const result = await client.query(
"select count(*)::text as n from public.chart_profiles where role = 'other' and (birth_time_status in ('accepted','confirmed') or active_birth_time is not null or active_birth_date is not null or active_birth_timezone_offset is not null)",
);
const count = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(count)) process.exit(2);
process.stdout.write(count);
} else if (kind === "archived" || kind === "self") {
const spec = kind === "archived"
? { table: "chat_sessions", column: "archived_at", whereSql: "archived_at is not null" }
: { table: "chart_profiles", column: "role", whereSql: "role = 'self'" };
const exists = await client.query(
"select exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = $1 and column_name = $2) as present",
[spec.table, spec.column],
);
let count = "absent";
if (exists.rows[0]?.present === true) {
const result = await client.query(
`select count(*)::text as n from public.${spec.table} where ${spec.whereSql}`,
);
count = result.rows[0]?.n ?? "";
}
if (!/^[0-9]+$/.test(count) && count !== "absent") process.exit(2);
process.stdout.write(count);
} else if (kind === "grants") {
const result = await client.query(`
select line from (
select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name as line
from information_schema.role_table_grants
where table_schema not in ('pg_catalog', 'information_schema')
union all
select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name || '|' || column_name
from information_schema.column_privileges
where table_schema not in ('pg_catalog', 'information_schema')
) s
order by 1
`);
for (const row of result.rows) process.stdout.write(`${row.line}\n`);
} else if (kind === "bug1063") {
const columns = [
"id", "user_id", "role", "name", "birth_date", "reported_birth_time",
"birth_time_source", "birth_time_period", "declared_window_start",
"declared_window_end", "uncertainty_before_minutes", "uncertainty_after_minutes",
"latitude", "longitude", "timezone_id", "timezone_offset", "birth_place_label",
"birth_place_type", "birth_place_provider", "birth_place_provider_id", "ayanamsa",
"active_birth_time", "active_birth_date", "active_birth_timezone_offset",
"birth_time_status", "gender",
];
const result = await client.query(
`select count(*)::text as n
from unnest($1::text[]) as cols(column_name)
where not has_column_privilege('service_role', 'public.chart_profiles'::regclass, cols.column_name, 'SELECT')`,
[columns],
);
const missing = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(missing)) process.exit(2);
process.stdout.write(missing);
} else if (kind === "grant_create") {
await client.query(`grant connect, create on database ${name} to schema_owner`);
} else if (kind === "drop") {
await client.end();
const admin = new URL(source);
admin.pathname = "/postgres";
const dropper = new pg.Client({ connectionString: admin.toString() });
await dropper.connect();
await dropper.query(`drop database if exists ${name} with (force)`);
await dropper.end();
process.exit(0);
} else {
process.exit(2);
}
await client.end();
PY
)
}
drop_rehearse_database() {
[ "${REHEARSAL_CREATED:-}" = 1 ] || return 0
REHEARSAL_CREATED=0
local status=0
if [ "$local_mode" -eq 0 ]; then
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"${database_name}\" WITH (FORCE);" >/dev/null || status=$?
else
run_node_sql drop >/dev/null || status=$?
fi
unset POSTGRES_PASSWORD || true
return "$status"
}
say_cleanup_failed() {
echo "演练库未删除,请手动 DROP DATABASE ${database_name}" >&2
}
local_mode=0
if [ "${JYOTISHA_REHEARSAL_LOCAL:-}" = 1 ]; then
local_mode=1
fi
if [ "$local_mode" -eq 1 ]; then
: "${JYOTISHA_REHEARSAL_DATABASE_URL:?JYOTISHA_REHEARSAL_DATABASE_URL is required}"
[[ "$JYOTISHA_REHEARSAL_DATABASE_URL" == postgresql://* ]] || refuse "rehearsal URL must be a PostgreSQL URL"
case "$JYOTISHA_REHEARSAL_DATABASE_URL" in
*\?*) refuse "rehearsal URL must not include query parameters" ;;
esac
database_name="$(database_name_from_url "$JYOTISHA_REHEARSAL_DATABASE_URL")"
assert_rehearse_name "$database_name"
export SCHEMA_DATABASE_URL="$JYOTISHA_REHEARSAL_DATABASE_URL"
unset JYOTISHA_REHEARSAL_DATABASE_URL
else
: "${DEPLOY_PATH:?DEPLOY_PATH is required}"
if [ "${JYOTISHA_REHEARSAL_FAKE_HOST:-}" = 1 ]; then
case "$DEPLOY_PATH" in
/opt/jyotisha-production|/opt/jyotisha-production/*)
refuse "fake host mode cannot use the production path"
;;
esac
else
[ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || refuse "unexpected production path"
fi
database_name="rehearse_$(date -u +%Y%m%d%H%M%S)"
assert_rehearse_name "$database_name"
fi
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/jyotisha-rehearse.XXXXXX")"
cleanup_work() {
local status=$?
if ! drop_rehearse_database; then
say_cleanup_failed
status=1
fi
rm -rf -- "$work_dir"
exit "$status"
}
trap cleanup_work EXIT HUP INT TERM
copy_batch "$work_dir/before" before
copy_batch "$work_dir/all" all
before_count="$(count_sql "$work_dir/before")"
all_count="$(count_sql "$work_dir/all")"
if [ -e "$work_dir/before/$STOP_BEFORE" ]; then
refuse "stop file leaked into the first batch"
fi
[ -f "$work_dir/all/$STOP_BEFORE" ] || refuse "stop file is missing from the migration set"
if [ "${JYOTISHA_REHEARSAL_PLAN:-}" = 1 ]; then
trap - EXIT HUP INT TERM
rm -rf -- "$work_dir"
printf 'rehearsal_plan=1\n'
printf 'database_name=%s\n' "$database_name"
printf 'batch_before=%s\n' "$before_count"
printf 'batch_all=%s\n' "$all_count"
printf 'stop_before=%s\n' "$STOP_BEFORE"
printf 'stop_in_before=no\n'
exit 0
fi
if [ "$local_mode" -eq 0 ]; then
: "${RECOVERY_DUMP:?RECOVERY_DUMP is required}"
[ -f "$RECOVERY_DUMP" ] && [ ! -L "$RECOVERY_DUMP" ] || refuse "recovery dump is missing or unsafe"
case "$RECOVERY_DUMP" in
"$DEPLOY_PATH"/backups/*.dump.enc) ;;
*) refuse "recovery dump must be an encrypted file under the production backup directory" ;;
esac
environment_file="$DEPLOY_PATH/.env.production.database"
[ -f "$environment_file" ] && [ ! -L "$environment_file" ] || refuse "production database environment file is missing or unsafe"
state_directory="$DEPLOY_PATH/.state"
backup_directory="$DEPLOY_PATH/backups"
lock_file="$state_directory/mutation.lock"
[ -d "$state_directory" ] && [ ! -L "$state_directory" ] || refuse "production state directory is unsafe"
[ -d "$backup_directory" ] && [ ! -L "$backup_directory" ] || refuse "production backup directory is unsafe"
exec 9>"$lock_file"
if [ "${JYOTISHA_REHEARSAL_FAKE_HOST:-}" != 1 ]; then
flock -n 9 || {
echo "another production mutation holds the host lock" >&2
exit 75
}
fi
set -a
# shellcheck disable=SC1090
. "$environment_file"
set +a
: "${POSTGRES_DB:?}" "${POSTGRES_USER:?}" "${POSTGRES_PASSWORD:?}" "${PRODUCTION_BACKUP_ENCRYPTION_KEY:?}"
[ "$POSTGRES_DB" = "jyotisha" ] || refuse "unexpected production database name"
usage_percent="$(df -Pk "$backup_directory" | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')"
[[ "$usage_percent" =~ ^[0-9]+$ ]] && (( usage_percent < 70 )) || refuse "production backup disk usage must remain below 70 percent"
mapfile -t postgres_containers < <(
docker_cli ps -q \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter 'label=com.docker.compose.service=postgres'
)
[ "${#postgres_containers[@]}" -eq 1 ] || refuse "expected exactly one running production PostgreSQL container"
postgres_container="${postgres_containers[0]}"
mapfile -t web_containers < <(
docker_cli ps -q \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter 'label=com.docker.compose.service=web'
)
[ "${#web_containers[@]}" -eq 1 ] || refuse "expected exactly one running production web container"
web_container="${web_containers[0]}"
web_image="$(docker_cli inspect --format '{{.Image}}' "$web_container")"
[[ "$web_image" =~ ^sha256:[0-9a-f]{64}$ ]] || refuse "production web image identity is unsafe"
web_network="$(docker_cli inspect --format '{{range $name, $net := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' "$web_container" | head -n 1)"
[ -n "$web_network" ] || refuse "production web network is missing"
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE \"$database_name\";" >/dev/null
REHEARSAL_CREATED=1
set -o pipefail
openssl enc -d -aes-256-cbc -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$RECOVERY_DUMP" |
docker_cli exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_restore -U "$POSTGRES_USER" -d "$database_name" \
--exit-on-error
set +o pipefail
unset PRODUCTION_BACKUP_ENCRYPTION_KEY
# The URL is captured only into the environment, never echoed.
SCHEMA_DATABASE_URL="$(python3 - "$POSTGRES_USER" "$POSTGRES_PASSWORD" "$database_name" <<'PY'
import sys, urllib.parse
user, password, name = sys.argv[1:]
if name == "jyotisha" or not name.startswith("rehearse_"):
sys.exit(2)
print("postgresql://{user}:{password}@postgres:5432/{name}?options=-c%20role%3Dschema_owner".format(
user=urllib.parse.quote(user, safe=""),
password=urllib.parse.quote(password, safe=""),
name=name,
))
PY
)"
export SCHEMA_DATABASE_URL
run_batch() {
local batch="$1" log="$2" status
set +e
docker_cli run --rm --network "$web_network" \
-e SCHEMA_DATABASE_URL \
-e MIGRATIONS_DIRECTORY=/migrations \
-v "$batch:/migrations:ro" \
-v "$frontend_dir/scripts/db-migrate.mjs:/app/frontend/scripts/db-migrate.rehearse.mjs:ro" \
-w /app/frontend \
--entrypoint node \
"$web_image" scripts/db-migrate.rehearse.mjs >"$log" 2>&1
status=$?
set -e
return "$status"
}
else
REHEARSAL_CREATED=1
run_batch() {
run_node_migrator "$1" "$2"
return 0
}
fi
column_present() {
local table="$1" column="$2" present
present="$(docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = '${table}' and column_name = '${column}') then 'yes' else 'no' end")"
[ "$present" = "yes" ]
}
sql_scalar() {
local kind="$1" sql
if [ "$local_mode" -eq 1 ]; then
run_node_sql "$kind"
return
fi
case "$kind" in
count)
sql="select count(*)::text from public.chart_profiles where role = 'other' and (birth_time_status in ('accepted','confirmed') or active_birth_time is not null or active_birth_date is not null or active_birth_timezone_offset is not null)"
;;
archived)
if ! column_present chat_sessions archived_at; then
printf 'absent'
return
fi
sql="select count(*)::text from public.chat_sessions where archived_at is not null"
;;
self)
if ! column_present chart_profiles role; then
printf 'absent'
return
fi
sql="select count(*)::text from public.chart_profiles where role = 'self'"
;;
*)
return 2
;;
esac
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c "$sql"
}
capture_grants() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql grants
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select line from (select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name as line from information_schema.role_table_grants where table_schema not in ('pg_catalog', 'information_schema') union all select grantee || '|' || privilege_type || '|' || table_schema || '|' || table_name || '|' || column_name from information_schema.column_privileges where table_schema not in ('pg_catalog', 'information_schema')) s order by 1"
}
chart_profiles_select_missing() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql bug1063
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 -At -c \
"select count(*)::text from (values ('id'),('user_id'),('role'),('name'),('birth_date'),('reported_birth_time'),('birth_time_source'),('birth_time_period'),('declared_window_start'),('declared_window_end'),('uncertainty_before_minutes'),('uncertainty_after_minutes'),('latitude'),('longitude'),('timezone_id'),('timezone_offset'),('birth_place_label'),('birth_place_type'),('birth_place_provider'),('birth_place_provider_id'),('ayanamsa'),('active_birth_time'),('active_birth_date'),('active_birth_timezone_offset'),('birth_time_status'),('gender')) as cols(column_name) where not has_column_privilege('service_role', 'public.chart_profiles'::regclass, cols.column_name, 'SELECT')"
}
grant_migration_database() {
if [ "$local_mode" -eq 1 ]; then
run_node_sql grant_create
return
fi
docker_cli exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database_name" -v ON_ERROR_STOP=1 \
-c "GRANT CONNECT, CREATE ON DATABASE \"${database_name}\" TO schema_owner;" >/dev/null
}
echo "rehearsal_database=$database_name"
echo "batch_before=$before_count"
echo "stop_before=$STOP_BEFORE"
archived_before="$(sql_scalar archived)" || refuse "could not count archived sessions before migrations"
self_before="$(sql_scalar self)" || refuse "could not count self chart rows before migrations"
echo "archived_sessions_before=$archived_before"
echo "self_chart_rows_before=$self_before"
grants_before="$work_dir/grants-before.txt"
capture_grants >"$grants_before" || refuse "could not read grants before migrations"
grant_migration_database || refuse "could not grant the migration role on the rehearsal database"
set +e
run_batch "$work_dir/before" "$work_dir/before.log"
before_status=$?
set -e
if [ "$local_mode" -eq 0 ]; then
print_safe_log "$work_dir/before.log"
fi
[ "$before_status" -eq 0 ] || refuse "rehearsal migration failed"
other_rows="$(sql_scalar count)" || refuse "could not count other-person profiles before the stop file"
echo "other_person_accepted_rows=$other_rows"
if [ "$other_rows" != 0 ]; then
echo "stop: $STOP_BEFORE would change $other_rows other-person profiles; production was not changed" >&2
exit 1
fi
echo "batch_all=$all_count"
set +e
run_batch "$work_dir/all" "$work_dir/all.log"
all_status=$?
set -e
if [ "$local_mode" -eq 0 ]; then
print_safe_log "$work_dir/all.log"
fi
[ "$all_status" -eq 0 ] || refuse "rehearsal migration failed"
archived_after="$(sql_scalar archived)" || refuse "could not count archived sessions after migrations"
self_after="$(sql_scalar self)" || refuse "could not count self chart rows after migrations"
echo "archived_sessions_after=$archived_after"
echo "self_chart_rows_after=$self_after"
notice_lines=0
for log in "$work_dir/before.log" "$work_dir/all.log"; do
[ -f "$log" ] || continue
notice_lines=$((notice_lines + $(grep -c '^notice ' "$log" || true)))
done
echo "notice_lines=$notice_lines"
grants_after="$work_dir/grants-after.txt"
capture_grants >"$grants_after" || refuse "could not read grants after migrations"
revoked="$(comm -23 <(sort "$grants_before") <(sort "$grants_after") | wc -l | tr -d '[:space:]')"
echo "revoked_grants=$revoked"
if [ "$revoked" != 0 ]; then
refuse "migration revoked existing grants"
fi
if ! missing="$(chart_profiles_select_missing | tr -d '[:space:]')"; then
refuse "could not check chart_profiles column grants"
fi
[[ "$missing" =~ ^[0-9]+$ ]] || refuse "could not check chart_profiles column grants"
echo "chart_profiles_service_role_select_missing=$missing"
if [ "$missing" != 0 ]; then
echo "stop: rehearsal copy still lacks service_role column SELECT on chart_profiles after migrations; do not migrate production" >&2
exit 4
fi
echo "rehearsal_ok=1"
exit 0
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env bash
# Resolve the production release SHA shared by the four production workflows.
#
# Validate mode (default): REQUESTED_SHA must be a successful staging push of
# backend-quality-gate, main and staging must be the same head H, and
# is-docs-only-range.sh REQUESTED_SHA H must exit 0.
# Discover mode (RESOLVE_MODE=discover): R is the newest such push whose range
# to H is docs-only.
# CHECK_RELEASE_GATE=true also requires a successful release-quality-gate
# dispatch whose head is R or H.
#
# stdout is three KEY=VALUE lines: release_sha, gate_run_id, head_sha.
# Exit 0 accepted, 1 refused, 2 when the Gitea API cannot be read.
# Tests set JYOTISHA_HTTP_FIXTURE_DIR and JYOTISHA_DOCS_ONLY_MODE=git.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
python="${JYOTISHA_PYTHON:-python3}"
mode="${RESOLVE_MODE:-validate}"
api_url="${GITEA_API_URL:-https://git.copse.top/api/v1}"
repository="${GITEA_REPOSITORY:-root/Jyotisha}"
docs_mode="${JYOTISHA_DOCS_ONLY_MODE:-api}"
if [[ "$mode" != validate && "$mode" != discover ]]; then
echo "RESOLVE_MODE must be validate or discover" >&2
exit 2
fi
http_get() {
local url="$1" dest="$2"
if [ -n "${JYOTISHA_HTTP_FIXTURE_DIR:-}" ]; then
if ! URL="$url" DEST="$dest" FIXTURE_DIR="$JYOTISHA_HTTP_FIXTURE_DIR" "$python" - <<'PY'
import json, os, sys
from pathlib import Path
url = os.environ["URL"]
root = Path(os.environ["FIXTURE_DIR"])
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
for item in manifest:
if item["match"] in url:
status = int(item.get("status", 200))
if status != 200:
sys.stderr.write("fixture refused the Gitea request\n")
sys.exit(1)
Path(os.environ["DEST"]).write_bytes((root / item["file"]).read_bytes())
sys.exit(0)
sys.stderr.write("no fixture matched the Gitea request\n")
sys.exit(1)
PY
then
return 1
fi
return 0
fi
local -a auth=()
if [ -n "${GITEA_TOKEN:-}" ]; then
auth=(--header "Authorization: token ${GITEA_TOKEN}")
fi
if ! curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \
"${auth[@]}" "$url" --output "$dest"; then
return 1
fi
}
fetch_json() {
local url="$1" dest
dest="$(mktemp)"
if ! http_get "$url" "$dest"; then
rm -f -- "$dest"
echo "Gitea API request failed" >&2
exit 2
fi
printf '%s' "$dest"
}
read_ref_sha() {
local branch="$1" dest sha
dest="$(fetch_json "$api_url/repos/$repository/git/refs/heads/$branch")"
if ! sha="$("$python" - "$branch" "$dest" <<'PY'
import json, re, sys
branch, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
ref = "refs/heads/" + branch
if not isinstance(payload, list) or len(payload) != 1 or payload[0].get("ref") != ref:
sys.exit(2)
sha = ((payload[0].get("object") or {}).get("sha") or "")
if not re.fullmatch(r"[0-9a-f]{40}", sha):
sys.exit(2)
print(sha)
PY
)"; then
rm -f -- "$dest"
echo "Gitea ref for $branch is unreadable" >&2
exit 2
fi
rm -f -- "$dest"
printf '%s' "$sha"
}
gate_id_for() {
local sha="$1" dest gate_id
dest="$(fetch_json "$api_url/repos/$repository/actions/runs?head_sha=$sha&branch=staging&event=push&status=success&limit=100")"
set +e
gate_id="$("$python" - "$sha" "$dest" <<'PY'
import json, sys
sha, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
runs = payload.get("workflow_runs") or []
matched = []
for run in runs:
workflow = str(run.get("path") or "").split("@", 1)[0]
if (
workflow.endswith("backend-quality-gate.yml")
and run.get("head_sha") == sha
and run.get("head_branch") == "staging"
and run.get("event") == "push"
and run.get("conclusion") == "success"
):
matched.append(run)
if not matched:
sys.exit(1)
matched.sort(key=lambda run: int(run.get("id") or 0), reverse=True)
print(int(matched[0]["id"]))
PY
)"
local status=$?
set -e
rm -f -- "$dest"
if [ "$status" -eq 2 ]; then
echo "Gitea gate run list is unreadable" >&2
exit 2
fi
if [ "$status" -ne 0 ]; then
echo "no successful exact-SHA production quality gate run found" >&2
exit 1
fi
printf '%s' "$gate_id"
}
require_docs_only() {
local base="$1" head="$2" status
set +e
bash "$script_dir/is-docs-only-range.sh" "--$docs_mode" "$base" "$head" >&2
status=$?
set -e
if [ "$status" -ne 0 ]; then
echo "release SHA is not a docs-only ancestor of the staging head" >&2
exit 1
fi
}
require_release_gate() {
local release="$1" head="$2" sha dest status found=false
for sha in "$release" "$head"; do
dest="$(fetch_json "$api_url/repos/$repository/actions/runs?head_sha=$sha&event=workflow_dispatch&status=success&limit=100")"
set +e
"$python" - "$sha" "$dest" <<'PY'
import json, sys
sha, path = sys.argv[1], sys.argv[2]
try:
payload = json.loads(open(path, encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
runs = payload.get("workflow_runs") or []
for run in runs:
workflow = str(run.get("path") or "").split("@", 1)[0]
if (
workflow.endswith("release-quality-gate.yml")
and run.get("head_sha") == sha
and run.get("event") == "workflow_dispatch"
and run.get("conclusion") == "success"
):
sys.exit(0)
sys.exit(1)
PY
status=$?
set -e
rm -f -- "$dest"
if [ "$status" -eq 0 ]; then
found=true
break
fi
if [ "$status" -eq 2 ]; then
echo "Gitea release gate list is unreadable" >&2
exit 2
fi
done
if [ "$found" != true ]; then
echo "no successful manual release quality gate found for the release SHA or current main" >&2
exit 1
fi
}
staging_head="$(read_ref_sha staging)"
main_head="$(read_ref_sha main)"
if [ "$main_head" != "$staging_head" ]; then
echo "main and staging must identify the same reviewed release" >&2
exit 1
fi
head_sha="$staging_head"
if [ "$mode" = validate ]; then
[[ "${REQUESTED_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || {
echo "deploy_sha must be a lowercase full commit SHA" >&2
exit 1
}
release_sha="$REQUESTED_SHA"
gate_run_id="$(gate_id_for "$release_sha")"
require_docs_only "$release_sha" "$head_sha"
else
list="$(fetch_json "$api_url/repos/$repository/actions/runs?branch=staging&event=push&status=success&limit=100")"
set +e
candidates="$("$python" - "$list" <<'PY'
import json, sys
try:
payload = json.loads(open(sys.argv[1], encoding="utf-8").read())
except (OSError, json.JSONDecodeError):
sys.exit(2)
best = {}
for run in payload.get("workflow_runs") or []:
workflow = str(run.get("path") or "").split("@", 1)[0]
sha = run.get("head_sha") or ""
if (
workflow.endswith("backend-quality-gate.yml")
and run.get("head_branch") == "staging"
and run.get("event") == "push"
and run.get("conclusion") == "success"
and len(sha) == 40
):
run_id = int(run.get("id") or 0)
if sha not in best or run_id > best[sha]:
best[sha] = run_id
for sha, run_id in sorted(best.items(), key=lambda item: item[1], reverse=True):
print(f"{sha} {run_id}")
PY
)"
status=$?
set -e
rm -f -- "$list"
if [ "$status" -eq 2 ]; then
echo "Gitea gate run list is unreadable" >&2
exit 2
fi
release_sha=""
gate_run_id=""
while read -r candidate_sha candidate_id; do
[ -n "$candidate_sha" ] || continue
set +e
bash "$script_dir/is-docs-only-range.sh" "--$docs_mode" "$candidate_sha" "$head_sha" >&2
status=$?
set -e
if [ "$status" -eq 0 ]; then
release_sha="$candidate_sha"
gate_run_id="$candidate_id"
break
fi
done <<<"$candidates"
if [ -z "$release_sha" ]; then
echo "no docs-only staging quality gate can be released" >&2
exit 1
fi
fi
if [ "${CHECK_RELEASE_GATE:-}" = true ]; then
require_release_gate "$release_sha" "$head_sha"
fi
printf 'release_sha=%s\n' "$release_sha"
printf 'gate_run_id=%s\n' "$gate_run_id"
printf 'head_sha=%s\n' "$head_sha"
+12
View File
@@ -34,6 +34,18 @@ if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then
echo "invalid rollback authorization" >&2
exit 1
fi
if [ "$ALLOW_ROLLBACK" = "true" ]; then
recovery_baseline="$DEPLOY_PATH/.state/recovery-baseline.env"
[ -f "$recovery_baseline" ] && [ ! -L "$recovery_baseline" ] || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_format="$(awk -F= '$1 == "RECOVERY_FORMAT" { print $2 }' "$recovery_baseline")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
fi
if [ "$VERIFICATION_MODE" != "internal" ] && [ "$VERIFICATION_MODE" != "public" ]; then
echo "invalid production verification mode" >&2
exit 1
+10
View File
@@ -51,6 +51,16 @@ esac
echo "production recovery point must have restore_verified=true" >&2
exit 1
}
recovery_baseline="$DEPLOY_PATH/.state/recovery-baseline.env"
[ -f "$recovery_baseline" ] && [ ! -L "$recovery_baseline" ] || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_format="$(awk -F= '$1 == "RECOVERY_FORMAT" { print $2 }' "$recovery_baseline")"
[[ "$recovery_format" =~ ^[0-9]+$ ]] && (( recovery_format >= 2 )) || {
echo "recovery format is below 2; dispatch Create production recovery first" >&2
exit 1
}
recovery_created_epoch="$(date -u -d "$RECOVERY_CREATED_AT" +%s 2>/dev/null)" || {
echo "invalid production recovery creation time" >&2
exit 1
+59 -5
View File
@@ -88,6 +88,48 @@ set -a
set +a
: "${POSTGRES_DB:?}" "${POSTGRES_USER:?}" "${POSTGRES_PASSWORD:?}" "${PRODUCTION_BACKUP_ENCRYPTION_KEY:?}"
privilege_sql() {
case "$1" in
table_grants)
printf '%s' "select grantee || '|' || privilege_type || '|' || table_name from information_schema.role_table_grants where table_schema = 'public' order by 1"
;;
column_privileges)
printf '%s' "select grantee || '|' || privilege_type || '|' || table_name || '|' || column_name from information_schema.column_privileges where table_schema = 'public' order by 1"
;;
table_owners)
printf '%s' "select c.relname || '|' || pg_get_userbyid(c.relowner) from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r', 'p') order by 1"
;;
*)
echo "unknown privilege check" >&2
exit 1
;;
esac
}
privilege_snapshot() {
local database="$1" kind="$2" destination="$3"
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d "$database" -At -v ON_ERROR_STOP=1 \
-c "$(privilege_sql "$kind")" | sort >"$destination"
}
privileges_match() {
local kind="$1" source_file restore_file status=0
source_file="$(mktemp)"
restore_file="$(mktemp)"
if ! privilege_snapshot "$POSTGRES_DB" "$kind" "$source_file"; then
rm -f -- "$source_file" "$restore_file"
return 1
fi
if ! privilege_snapshot "$restore_database" "$kind" "$restore_file"; then
rm -f -- "$source_file" "$restore_file"
return 1
fi
cmp -s "$source_file" "$restore_file" || status=$?
rm -f -- "$source_file" "$restore_file"
return "$status"
}
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
@@ -111,7 +153,7 @@ cleanup() {
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 |
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom |
openssl enc -aes-256-cbc -salt -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY >"$backup_partial"
[ -s "$backup_partial" ]
@@ -127,7 +169,7 @@ 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
--exit-on-error
counts="$({
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
@@ -146,6 +188,17 @@ for value in "$identity_users" "$profiles" "$credit_transactions" "$public_table
}
done
table_grants_match=false
column_privileges_match=false
table_owners_match=false
if privileges_match table_grants; then table_grants_match=true; fi
if privileges_match column_privileges; then column_privileges_match=true; fi
if privileges_match table_owners; then table_owners_match=true; fi
if [ "$table_grants_match" != true ] || [ "$column_privileges_match" != true ] || [ "$table_owners_match" != true ]; then
echo "restore privilege verification failed" >&2
exit 1
fi
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
@@ -154,14 +207,15 @@ 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' \
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, "table_grants_match": %s, "column_privileges_match": %s, "table_owners_match": %s}\n}\n' \
"$backup_basename" "$backup_sha256" "$created_at" "$verified_at" \
"$identity_users" "$profiles" "$credit_transactions" "$public_tables" >"$verify_file"
"$identity_users" "$profiles" "$credit_transactions" "$public_tables" \
"$table_grants_match" "$column_privileges_match" "$table_owners_match" >"$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' \
printf 'RECOVERY_REFERENCE=%s\nRECOVERY_CREATED_AT=%s\nRECOVERY_SHA256=%s\nRESTORE_VERIFIED=true\nRECOVERY_FORMAT=2\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"
+5
View File
@@ -155,4 +155,9 @@ if [ "$personal_report_daily_limit_count" -ne 1 ] ||
exit 1
fi
if grep -Eq '^[[:space:]]*(export[[:space:]]+)?IDENTITY_TEST_OTP=' "$ENV_FILE"; then
echo "IDENTITY_TEST_OTP is forbidden in production" >&2
exit 1
fi
echo "production environment selectors: valid"
+36133 -18020
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
# 生产待上线迁移(2026-10-14)
对照 `origin/main` 与本分支基线,`frontend/supabase/migrations` 新增 63 个文件,没有修改或删除。`frontend/db/migrations` 在这之间是 0 个。迁移文件本身不改。
分级看的是去掉函数体之后还留在文件顶层的语句。`A` 是用户能直接看见的改写或删除。`B` 是范围有限的改写。`C` 是顶层还有收回权限。`D` 是只加表、列、函数或授权。
任务书初筛是 A 2、B 5、C 约 45、D 其余。复核后是 A 2、B 7、C 42、D 12。多出来的两条 B 是写在匿名代码块里的标题修补,机械筛会把它们看成没有顶层更新。
重复的时间戳只记录、不改名:`20260816010000` 有两个文件,`20260920010000` 有两个文件。仓库里另外还有 `20260806010000` 和 `frontend/db/migrations` 的 `20260807010000`,不在这 63 个里面。
## 产品需知
- `20260921010000`:以前归档的会话会全部回到列表。这是取消归档功能时已经定过的。
- `20260925020000`:人物表里「本人」那一行会删掉,本人资料只留在账户资料里。
- `20260816010000_accept_exact_family_birth_times`:家人整点时间会变成已采用。
- `20260822010000`:只知道时段的,会补上开始和结束钟点。
- `20260823010000`:没有日期的职业、外貌、胎记证据会变成已确认。
- `20260906030000`:时间完全未知且还没采用的校正,会先进入时段比较。
- `20260916020000`:校正标题修补。生产上预期是 0 行。NOTICE 不是 0 就停下,只报两个数字。
- `20260925010000`:自动校正标题会改成带结果。只看 NOTICE 的行数。
- `20260925030000`:他人档案里已采用或已确认的时间会退回已申报。演练在跑这个文件之前先数行,预期 0。不是 0 就停下,只把数字报给产品,不要继续。
上面这 63 个文件里没有服务角色读人物表的授权(BUG-1063)。测试环境后来多了 `20261014010000_chart_profiles_service_role_select.sql`,它排在停止文件之后,会进演练的第二批。冒烟看的是跑完迁移之后的演练库:仍是 false 就退出码 4,不要点生产迁移。这不是去问正在服务的生产库。
## 63 条
| 文件 | 级 | 作用 | 对生产用户 | 演练要看 |
| --- | --- | --- | --- | --- |
| `20260816010000_accept_exact_family_birth_times.sql` | B | 家人给出的整点时间,从「已申报」改成「已采用」。 | 只动还没做过校正、并且本来就是整点的家人时间。 | 迁移前后数 public.profiles 里被这条命中的行。 |
| `20260816010000_admin_redemption_admin_access.sql` | C | 兑换码管理改成普通后台能力,并收紧权限。 | 不改用户已有的点数或兑换记录。 | 迁移无报错;后台仍只有管理员能进。 |
| `20260817010000_billing_catalog_admin_runtime_read_policies.sql` | D | 给后台只读价格目录加策略。 | 用户看不到新入口,也不改已有订单。 | 迁移无报错。 |
| `20260818010000_admin_product_catalog_mutations.sql` | C | 后台改价格目录的权限收紧。 | 不改用户已购内容。 | 迁移无报错。 |
| `20260819010000_rectification_ingest_precision_plateau.sql` | C | 校正录入的精度判断收紧。 | 不改已填写的出生时间。 | 迁移无报错。 |
| `20260820010000_rectification_family_appearance_d12.sql` | C | 校正增加家人外貌相关判断,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
| `20260820020000_rectification_d5_d7_precision.sql` | C | 校正增加两类分盘精度,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
| `20260820030000_rectification_d3_family.sql` | C | 校正增加家人分盘,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
| `20260820040000_rectification_occupation_horary.sql` | C | 校正增加职业问事,并收紧函数权限。 | 不改已保存的档案。 | 迁移无报错。 |
| `20260822010000_declared_birth_window_clocks.sql` | B | 把「只知道大概时段」回填成开始和结束钟点。 | 只填原来空白的时段列,不改已经写上的时间。 | 数 public.profiles 里时段来源且两列仍空的行,迁移后应变少。 |
| `20260823010000_rectification_occupation_dateless.sql` | B | 没有日期的职业、外貌、胎记证据可以记成已确认。 | 只改这三类证据的确认状态,不改出生时间。 | 数这三类证据在迁移前后的确认行数。 |
| `20260823020000_rectification_inference_choice_write.sql` | C | 校正选择题的写入权限收紧。 | 不改已有答案。 | 迁移无报错。 |
| `20260824010000_rectification_inference_transition_ledger.sql` | C | 校正推断过程改成只追加的记录,并收紧权限。 | 旧的推断结果仍在,不删。 | 迁移无报错。 |
| `20260824020000_rectification_choice_action.sql` | C | 校正点选和停止改成一次写完,并收紧权限。 | 不改已有对话。 | 迁移无报错。 |
| `20260824030000_rectification_turn_origin.sql` | C | 校正每句话记下是打字、点选、语音还是重试,并收紧权限。 | 不改已有对话正文。 | 迁移无报错。 |
| `20260825010000_rectification_evidence_kind_date_dedup.sql` | C | 同类又同日的校正证据去重规则收紧。 | 不删除用户原文。 | 迁移无报错。 |
| `20260826010000_rectification_inference_round_audit.sql` | C | 校正每一轮多记审计字段,并收紧权限。 | 不改已有结论。 | 迁移无报错。 |
| `20260826020000_rectification_choice_focus_identity.sql` | C | 校正选项卡用服务器保存的编号认身份,并收紧权限。 | 刚发出的卡不会因为编号对不上而被拒绝。 | 迁移无报错。 |
| `20260826030000_rectification_migration_ledger_health.sql` | C | 网页角色不能再直接读迁移账本,健康检查改走只返回文件名的函数。 | 用户界面不变。 | 迁移无报错。 |
| `20260827010000_rectification_candidate_profile_freshness.sql` | C | 校正候选和档案是否还新鲜的判断收紧。 | 不改已采用的时间。 | 迁移无报错。 |
| `20260827020000_rectification_vedastro_validation_retry.sql` | C | 校正外部核对失败后可以重试,并收紧权限。 | 不改已采用的时间。 | 迁移无报错。 |
| `20260830010000_chat_session_chart_profile_binding.sql` | D | 会话可以绑定到某一位已保存的人。 | 旧会话不改内容。 | 迁移无报错。 |
| `20260830020000_personal_report_sections.sql` | C | 个人报告按章节保存进度,并收紧权限。 | 已生成的报告正文还在。 | 迁移无报错。 |
| `20260831010000_feature_pricing.sql` | C | 功能价格表收紧权限。 | 不改用户余额。 | 迁移无报错。 |
| `20260831020000_feature_pricing_admin_runtime_read_policy.sql` | D | 后台可以只读功能价格。 | 用户侧不变。 | 迁移无报错。 |
| `20260901010000_append_consultation_question.sql` | C | 咨询提问的写入函数收紧。 | 不改已有对话。 | 迁移无报错。 |
| `20260901020000_chat_session_pin_archive.sql` | D | 会话增加置顶和归档用的列。 | 这一步还不把归档会话放回列表。 | 迁移无报错。 |
| `20260902010000_rectification_question_on_turn.sql` | C | 校正把问过的题和选中的项记在当轮上,并收紧权限。 | 刷新后还能看到刚才那道题。 | 迁移无报错。 |
| `20260902020000_rectification_tool_activity_timing.sql` | C | 校正工具耗时从已有记录推算,并收紧权限。 | 不新增用户能看到的个人资料。 | 迁移无报错。 |
| `20260903010000_profile_ayanamsa.sql` | D | 本人资料增加岁差设置。 | 不改已有星盘结论。 | 迁移无报错。 |
| `20260904010000_read_report_candidate_range.sql` | C | 报告只通过一个只读函数看校正窗口,表权限保持收回。 | 找不到时按未找到处理,不编一个窗口。 | 迁移无报错。 |
| `20260905010000_personal_report_longform_appendices.sql` | C | 长报告附录单独缓存,失败不影响报告本身,并收紧权限。 | 已完成的报告不会因为附录失败被改成失败。 | 迁移无报错。 |
| `20260906010000_chat_session_context_summary.sql` | D | 会话增加一段上下文摘要列。 | 旧对话正文不变。 | 迁移无报错。 |
| `20260906020000_adopted_credible_range.sql` | C | 采用校正结果时另存当时的可信窗口,并收紧权限。 | 原来的搜索起点不被改写。 | 迁移无报错。 |
| `20260906030000_rectification_block_scan_stage.sql` | B | 出生时间完全未知、而且尚未采用的校正,阶段改成先比较时段。 | 已经确认、关闭或已有具体时间的不改。 | 数 public.agentic_rectification_cases 里被改成 block_scan 的行。 |
| `20260907010000_personal_report_card_summary.sql` | D | 报告列表增加一段纯文字摘要。 | 不改报告正文。 | 迁移无报错。 |
| `20260907020000_rectification_declared_uncertainty.sql` | C | 校正可以按用户说的不确定范围收窄或放宽,并收紧权限。 | 不改已经采用的时间。 | 迁移无报错。 |
| `20260909010000_profile_ayanamsa_service_role_grants.sql` | D | 补岁差列给服务角色的读取权。 | 不改用户设置。 | 迁移无报错。 |
| `20260910010000_rectification_host_fallback_phase.sql` | C | 校正在模型没写出答案时记下宿主兜底,并收紧权限。 | 不改已有答案。 | 迁移无报错。 |
| `20260915010000_rectification_touch_chat_session.sql` | C | 校正有进展时,会话的更新时间跟着前进,并收紧权限。 | 只打开或改标题不会刷新这个时间。 | 迁移无报错。 |
| `20260916010000_consultation_session_capacity.sql` | C | 咨询字数上限只数用户看得到的正文,并收紧权限。 | 思考过程仍保存,但不占那一档额度。 | 迁移无报错。 |
| `20260916020000_rectification_session_title_repair.sql` | B | 校正会话标题里对不上的月日会按创建时间改回;更新时间只向前补。 | 生产停在 08-16 时标题是固定的「生时校正」,预期改 0 行。 | 只看两条 NOTICE 的数字。不是 0 就停下,不记录标题文字。 |
| `20260920010000_consultation_free_completion.sql` | C | 咨询完成时的免费收尾规则收紧。 | 不改已扣的点数。 | 迁移无报错。 |
| `20260920010000_rectification_receipt_result_identity.sql` | C | 校正回执改用最后一次完成结果的来源,并收紧权限。 | 不改已展示的结论形状。 | 迁移无报错。 |
| `20260920020000_adopted_birth_date.sql` | C | 增加采用后的日期和时区列,并收紧权限。 | 原来申报的日期不改。 | 迁移无报错。 |
| `20260920030000_rectification_dated_windows.sql` | C | 校正窗口可以带公历日期,并收紧权限。 | 已有结果和原始日期不改写。 | 迁移无报错。 |
| `20260921010000_retire_chat_session_archive.sql` | A | 所有已归档会话的归档标记清空,会话回到列表。 | 用户会重新看到以前归档的会话。列先留着,以后再删。 | 数 public.chat_sessions 里归档标记非空的行,迁移后应为 0。 |
| `20260925010000_rectification_session_title_result.sql` | B | 自动生成的校正标题改成带结果,并回填已有自动标题。 | 手改过的标题不动。 | 只看 NOTICE 的行数,不记录标题文字。 |
| `20260925020000_chart_subject_typed_columns.sql` | A | 他人档案改用单独的列;本人在人物表里的镜像行删除。 | 本人资料改由账户资料承载。他人档案还在。 | 数 role 为 self 的人物行,迁移后应为 0。不要打印行内容。 |
| `20260925030000_chart_subject_trust_and_limit.sql` | B | 他人档案里已采用或已确认的时间退回「已申报」,并清空采用列。 | 每人最多再保存 4 位他人。生产预期受影响行数是 0。 | 本文件之前先数命中行。不是 0 就停下,只报数字。 |
| `20260925040000_deleted_report_release.sql` | C | 已删除报告的释放规则收紧。 | 不把已删除报告重新公开。 | 迁移无报错。 |
| `20260926010000_rectification_telemetry.sql` | C | 校正交付时写一行不含身份的汇总,并收紧权限。 | 不保存出生时间或对话原文。 | 迁移无报错。 |
| `20260927010000_profile_gender.sql` | D | 账户和已保存的人增加可选性别。 | 不填也能继续用。 | 迁移无报错。 |
| `20260927020000_profile_adopted_birth_service_role_select.sql` | D | 补服务角色读取已采用日期列的权限。 | 不改已有日期。 | 迁移无报错。 |
| `20260928010000_profile_ayanamsa_six.sql` | D | 岁差选项从四种扩成六种。 | 原来的设置仍然有效。 | 迁移无报错。 |
| `20260930010000_user_consents.sql` | C | 增加用户协议和隐私政策的同意记录,并收紧权限。 | 只追加,不改写旧同意。 | 迁移无报错。 |
| `20260930020000_account_deletion_requests.sql` | C | 增加 7 天冷静期的注销申请,并收紧权限。 | 申请当时会退出全部登录。不立刻删资料。 | 迁移无报错。 |
| `20260930030000_user_feedback.sql` | C | 增加应用内反馈和回复评价的表,并收紧权限。 | 旧对话不改。 | 迁移无报错。 |
| `20260930040000_moderation_events.sql` | C | 增加审核命中记录,并收紧权限。 | 只记类别和规则编号,不记原文。 | 迁移无报错。 |
| `20260930050000_reply_quality_snapshots.sql` | C | 点了负向评价时保存那一轮的问题、回答和上下文,并收紧权限。 | 没有点评价的对话不写这份快照。 | 迁移无报错。演练输出不要带正文。 |
| `20261001010000_rectification_segment_adoption.sql` | C | 校正可以把一段时间里的某一分钟记为采用结果,并收紧权限。 | 这不是新的引擎候选,也不等于最终确认。 | 迁移无报错。 |
| `20261001020000_rectification_segment_checks.sql` | C | 校正时段结果的检查收紧。 | 不改已采用的时间。 | 迁移无报错。 |
| `20261002010000_rectification_dated_algorithm_generation.sql` | D | 校正记下算法版本,方便以后区分新旧打分。 | 不改已经交付的结果。 | 迁移无报错。 |
@@ -0,0 +1,158 @@
# 生产首发步骤(2026-10-14)
给产品负责人照着点。本单合入测试环境并部署完成之后再做。执行人不要把出生资料、姓名、邮箱、会话、密钥写进仓库或评论。
发布用两个提交:
- `H`:当时的测试环境分支头。`main` 要快进到这一个,不能合并出新提交。
- `R`:测试环境上最近一次「代码门禁成功」的提交。`H` 可以比 `R` 更靠前,但中间只能是文档。公网健康检查里的版本号必须等于 `R`。
四个按钮都在 Gitea Actions 里,要从 `main` 点。迁移演练没有第五个按钮。产品在生产主机上自己跑脚本,看下面第 5 步。
## 0. 前一天
冻结代码推送。文档可以继续推。确认测试环境健康检查的版本号仍是 `R`,并且从 `R` 到当前测试环境头没有代码改动。
## 1. 核对版本
打开 `https://staging.jyotisha.chat/api/health`。`deployment.gitCommit` 等于 `R`。
在仓库里执行 `deploy/is-docs-only-range.sh R <当前测试环境头>`。退出码 0 才继续。退出码 1 或 2 都停,不要发布。
## 2. 把 main 快进到测试环境头
用快进把 `main` 推到 `H`。不要做 merge。快进之后 `main` 和测试环境是同一个提交。
通过:Gitea 上两个分支指向同一个 40 位提交。
## 3. Release Quality Gate
Gitea → Jyotish Release Quality Gate → Use workflow from:`main`。不需要填发布提交。
通过:这一步成功,摘要里能看到 `release_sha`(这就是 `R`)和 `head_sha`(这就是 `H`),并写着相对 `R` 的后续改动只是文档。
失败:不要点后面的按钮。看失败那一行的原文,把原文交给验收,不要附带环境文件。
## 4. Create Production Recovery Point
Gitea → Create Production Recovery Point。Use workflow from:`main`。`deploy_sha` 填 `R`。
通过:成功结束,记下三样东西:`recovery_reference`、`recovery_created_at`、以及它已经写明还原验证通过。主机上的 `/opt/jyotisha-production/.state/recovery-baseline.env` 里必须有 `RECOVERY_FORMAT=2`。没有这一行,或者数字小于 2,都不要往下走:先再点一次创建恢复点。8 月以来不带权限的旧备份不能再拿来回退。加密备份在生产主机的备份目录里,不要打开、不要把密钥贴出来。
失败:停。不要迁移。
## 5. 在主机上演练迁移
没有单独的 Gitea 按钮。用部署账号在生产主机上跑。主机上如果还没有这份代码,只读检出 `R`,不要在主机上构建镜像。备份文件要自己填进 `RECOVERY_DUMP`,脚本不会猜哪一个是最新的。
先看计划,不连数据库:
```text
DEPLOY_PATH=/opt/jyotisha-production JYOTISHA_REHEARSAL_PLAN=1 bash deploy/rehearse-production-migrations.sh
```
通过:输出里 `stop_in_before=no`,并且一次性库的名字以 `rehearse_` 开头。
再正式跑。把刚才那个备份文件路径放进 `RECOVERY_DUMP`,文件必须在 `/opt/jyotisha-production/backups/` 下面,并且是 `.dump.enc`:
```text
DEPLOY_PATH=/opt/jyotisha-production RECOVERY_DUMP=/opt/jyotisha-production/backups/<刚才那个备份文件名> bash deploy/rehearse-production-migrations.sh
```
脚本只还原到一次性库,不会改名为 `jyotisha` 的生产库。新库带不走原来的建库权限,所以脚本只在这个一次性库上,把连接和建对象的权限交给迁移角色。生产库不动。磁盘使用率要低于 70%。另一项生产变更正在进行时会停。跑完会删掉一次性库。
通过,要同时满足:
- 输出有 `other_person_accepted_rows=0`。这是 `20260925030000` 之前的人数。不是 0 就停,只把这个数字发给产品,不要继续,也不要写出任何一行的内容。
- `archived_sessions_before` / `archived_sessions_after`:归档标记还在的会话数。迁移后应为 0。旧备份里还没有这一列时,前面那个数是 `absent`。
- `self_chart_rows_before` / `self_chart_rows_after`:人物表里「本人」那一行。迁移后应为 0。还没有 `role` 列时,前面那个数是 `absent`。
- `notice_lines`:两批迁移日志里 `notice` 的行数。标题修补和自动标题回填的数字在这些行里。生产上标题修补预期是 0;不是 0 就停,只报数字。
- `revoked_grants=0`。这是迁移前后授权的差:迁移不能收回备份里已经有的表权限或列权限。不是 0 就停,不要点生产迁移。
- `chart_profiles_service_role_select_missing=0`。这是服务角色对人物表每一列的读取权,缺几列这里就是几。不是 0 时脚本退出码是 4,并停住。不要点生产迁移。
- 最后一行是 `rehearsal_ok=1`,并且命令的退出码是 0。只看见这一行但退出码不是 0,不算通过。
演练结束后,在 postgres 容器里执行 `\l rehearse_*`,确认没有名字以 `rehearse_` 开头的库留下来。演练库删不掉时,脚本退出码不是 0,并写出「演练库未删除,请手动 DROP DATABASE rehearse_…」。只删它写出的那一个名字,不要删名为 `jyotisha` 的生产库。生产库还没动。把退出码和它打印的那一句原因留下来。不要复制数据库口令或备份密钥。
## 6. Migrate Production Database
只在第 5 步 `rehearsal_ok=1` 之后点。
Gitea → Migrate Production Database。Use workflow from:`main`。
- `deploy_sha`:`R`
- `recovery_reference`:第 4 步记下的引用
- `recovery_created_at`:第 4 步记下的时间,24 小时内
- `restore_verified`:true
通过:工作流成功,并且没有「恢复点超过 24 小时」或「版本对不上」的失败句。
失败:不要部署新代码。按下面的「失败后」整库还原。
## 7. Deploy production
Gitea → Deploy production。Use workflow from:`main`。
- `deploy_sha`:`R`
- `allow_rollback`:false
- `verification_mode`:public
通过:工作流成功。然后做 `docs/testing/production-first-release-20261014.md`。
## 失败后
接受整库还原到第 4 步那个恢复点。恢复点之后新写入的数据会丢掉。内测还没开放时,这个窗口里通常还没有新的用户数据。
旧代码读不了新的表结构。所以不能只把程序退回去。两件事都要做,都由产品负责人点,不要让执行代理代点。
1. 整库还原。没有单独按钮。只允许 `RECOVERY_FORMAT=2` 的恢复点。先看 `/opt/jyotisha-production/.state/recovery-baseline.env`:没有 `RECOVERY_FORMAT=2` 就停,回到第 4 步再点一次创建恢复点。8 月的旧备份不能用。第 4 步的 `recovery_reference` 斜杠后面就是备份文件名,例如 `production-pre-migration-20261014T120000Z.dump.enc`。文件在 `/opt/jyotisha-production/backups/` 下面。先 `sha256sum` 这个文件,和同一份 `recovery-baseline.env` 里的 `RECOVERY_SHA256` 对上,再继续。
**先证明备份能解开、能读,再动生产库。** 顺序不能反:先删库再发现备份解不开,网站就停在一个空库上。口令和密钥只用主机上已有的环境文件,不要贴出来,也不要 `echo`:
```text
cd /opt/jyotisha-production
set -a; . ./.env.production.database; set +a
backup=/opt/jyotisha-production/backups/<备份文件名>
sha256sum "$backup" # 必须等于 recovery-baseline.env 里的 RECOVERY_SHA256
postgres_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=postgres)
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup" \
| sudo -n docker exec -i "$postgres_id" pg_restore --list >/dev/null && echo backup_readable=1
```
上面只读备份、不碰任何库。必须看到 `backup_readable=1`;没看到(解密失败、文件损坏)就停,不要往下做,问产品负责人。
看到之后,再停 `api` 和 `web`,不要停 `postgres`:
```text
api_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=api)
web_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=web)
sudo -n docker stop $api_id $web_id
postgres_id=$(sudo -n docker ps -q --filter label=com.docker.compose.project=jyotisha-production --filter label=com.docker.compose.service=postgres)
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE jyotisha WITH (FORCE);"
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE jyotisha;"
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$backup" \
| sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
pg_restore -U "$POSTGRES_USER" -d jyotisha --exit-on-error
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
psql -U "$POSTGRES_USER" -d jyotisha -v ON_ERROR_STOP=1 \
-c "GRANT CONNECT, CREATE ON DATABASE jyotisha TO schema_owner;"
```
整库导出带不走数据库本身的权限。上面这一句只补回迁移角色在这个库上的连接和建对象权限,和主机最初的引导一致。不要删掉空的 `public`,备份会改它的属主,先删会让还原停住。
还原后数这四样,要和备份旁边的 `*-restore-verify.json` 里 `checks` 一致:`identity.users`、`public.profiles`、`public.credit_transactions`、`public` 里的表数量。同一份 `checks` 里 `table_grants_match`、`column_privileges_match`、`table_owners_match` 必须都是 true。这三样是创建恢复点时,源库和还原库的表权限、列权限、表属主已经对过。对不上就停,不要部署。
然后再用网站账号实际读一行。没有登录身份时,行级安全会把行藏起来,看起来像没读到,但那不是权限不够。先把身份设成库里已有的一条账户,再切换角色。下面这条必须成功(退出码 0),并且能读到一行。权限不够会直接失败,失败就停,不要部署:
```text
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_id" \
psql -U "$POSTGRES_USER" -d jyotisha -v ON_ERROR_STOP=1 \
-c "select set_config('request.jwt.claim.sub', (select id::text from public.profiles limit 1), false); set role authenticated; select 1 from public.profiles limit 1;"
```
2. 再点 Deploy production,`allow_rollback` 选 true,目标用发布前的生产版本 `7b620c7a`。点之前先在 Gitea 的 Actions 运行列表里确认:这个版本有一次成功的手动发布门禁(Release Quality Gate)。查不到就停,问产品负责人,不要点。测试环境头如果只多了文档、自己没有门禁记录,回滚用的控制器仍然是最近一次门禁通过的修订,不再因为这个被拒绝。目标版本仍必须在已经审查过的 main 历史里。回滚如果提示恢复点格式低于 2,同样先回到第 4 步创建新恢复点,不要改用旧备份。
还原并且部署成功之后,再按核对清单看健康检查。版本号应回到 `7b620c7a`。
@@ -0,0 +1,63 @@
# PROGRESS · 内测主路径:给别人出报告、AI 生成标识、状态板清账(issue #73)
- 分支:`codex/beta-main-path-20261014`
- 实际基线:`origin/staging` `f4c53e7f9fad1a3de08b613c3470f8e4a2f62116`
- 任务书基线写成 `48a2fadd`,计划评论里的 base 是 `13bde11539bc99f76330376145c756da22d149e3`。开工时远端 staging 已是上面的 `f4c53e7f`。方向没有改,不需要拍板。
- 产品 2026-10-14 已同意标识文案,并指定 BUG-1063 用逐列只读授权(任务书 §11)。
- 开工时 `docs/BUG_HISTORY.md` 最大号是 BUG-1346。本单新增 BUG-1347。BUG-1246 在本文件里没有对应标题,没有改。
- 未改 `page.tsx`。没有授写权限,没有改 RLS,没有改 `authenticated` 现有授权。Skill 版本不变。
## 做了什么
T0:给别人出报告的后台在授权之前读不到档案表。同一条数据库用例失败,错误原文是 `permission denied for table chart_profiles`,代码 `42501`。这不是绿的,所以继续做 T1。
T1:新迁移只给 `service_role` 增加档案表上、报告读档所需各列的 `SELECT`。合同测试核对选择列都在授权里,并且新文件里没有写授权、没有策略改动、没有改登录用户授权。BUG-1063 记为 fixed-pending-verify:部署并真机给别人出一份报告之前不标 resolved。
T2:对话输入框下方一行中文说明「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」报告的两种阅读页在标题下加「本报告由 AI 生成,仅供参考。」英文阅读用「This report is AI-generated and for reference only.」英文对话那句写在文案口径里,界面是中文,不另画一行。没有单独的公开分享页,两种阅读页都带上了这行。DESIGN、VOICE 同提交更新。
T2b 让掉:仓库里没有 PDF / DOCX 导出器。Markdown 下载是按字节锁定的,没有改生成正文,也没有往下载文件里写元数据。记在 BUG-1347。
T3–T5:状态行只改措辞和部署证据。下列提交都已在 `origin/staging` 上:`82c8aeb0`(906、907)、`dec36bce`(1080)、`8112f62b`(1128 字体部分)、`e62d0a32`(1244、1245)、`52d14e8f`(1252、1253、1254)、`b8385adb`(1255)、`c76fdda7`(1256)、`61d6f248`(1257)、`6ec8efd5`(1258)、`11333460`(1247–1251)、`0ed45a4b`(1259)。仍是 fixed-pending-verify 或 investigating,没有改成 resolved。1254 写明门禁 run 1747、1750、1753、1756、1759、1762 连续六次通过,再观察前不标 resolved。BUG-283 状态改为 superseded(由 BUG-299 取代),根因原文保留。
T6:`scratchpad/beta-smoke-checklist-20261014.md` 不在这棵树上。冒烟清单已经在 `docs/testing/beta-smoke-20261014.md`,本单只补了第 4、8、9 行(对话标识、他人报告、报告标题中英文标识)。
## 断言三栏
没有改既有断言。新增用例只加期望。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0,约 51 秒。中途一次失败是测试里 `createRef()` 没写元素类型,已改成文本框类型后重跑通过 |
| 规范 | `npm run lint --prefix frontend` | 退出码 0,0 error,125 条原有 warning。本单改的文件没有新增 warning |
| 定向测试 | `tsx --test`:AI 标识、授权合同、输入框合同、报告阅读 | tests 51,pass 51,fail 0,cancelled 0,退出码 0,约 3.3 秒 |
| 数据库 | `npm run test:db --prefix frontend` | 退出码 1。tests 82,pass 69,fail 13,cancelled 0,约 1390 秒。13 条失败的名字与 `tools/agent-loop/windows-known-failures.txt` 逐条相同,没有多出来的。本单那条是 ok 54 |
| 全量前端测试 / 构建 / Python 快速门 | 未跑 | 留给验收。本单不改 `scripts/` |
| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 退出码 0,约 59 秒 |
## 偏离
- 任务书点名的数据库文件可以新建 `database-report-worker-subject.test.ts`,用的是这个名字,没有改旧的 `database-chart-subject.test.ts`。
- 英文对话说明没有画在界面上,因为对话外壳只有中文。句子留在 VOICE。
- T2b 按让步顺序跳过。
- 冒烟草稿不在树里,改的是已经落盘的清单,没有另起一份。
## 第 1 次打回(2026-10-14)
接受最新 `origin/staging` 的合并。旧报告页 `personal-report-document-view.tsx` 按修复单删除,标识留在现在用的 Markdown 阅读页。AI 标识测试不再读取那个已删文件。更新日志和 Bug 历史两边都留着。Bug 历史在 staging 上本来就有前后两份,合并后仍是两份,本单只多了 BUG-1347 这一条。任务索引里补上修复单,并写上进度文件。
### 断言三栏(本轮)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/ai-generated-notice-20261014.test.ts` 报告阅读用例 | 渲染已删除的旧结构化报告页,并检查中文标识和 `id="report-disclaimer"` | 删掉对旧页面的读取和这两条断言;Markdown 中英文标题下的断言、输入框断言保留 | 第 1 次打回:旧页面已由另一单删除,现行报告页已有标识 |
### 本轮自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 |
| 规范 | `npm run lint --prefix frontend` | 退出码 0,0 error,123 warning(原有警告,本轮没有新增 error) |
| 定向测试 | 在 `frontend` 下 `tsx --test` 跑修复单点名的 3 个文件 | tests 7,pass 7,fail 0,cancelled 0,退出码 0 |
| 全量前端测试 / 构建 / 数据库 | 未跑 | 修复单不要求。数据库上一轮已过,这轮没改迁移 |
@@ -0,0 +1,70 @@
# PROGRESS · 发问准备并行(2026-10-14)
- 分支:`codex/consult-prep-parallel-20261013`
- 基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 的后代;计划评论里的 `2994588ad` 更早,不因此停工)
- 个人长报告写作开关仍是关闭,本轮没有改报告章节
- 编号:本分支上的 `docs/BUG_HISTORY.md` 最大号是 BUG-1322。BUG-1323~1336 被其它未合并任务书占用。本单用 BUG-1337~1339
## 开关
三个都保持代码里的默认,没有单独提交去打开:
| 开关 | 默认 | 原因 |
| --- | --- | --- |
| `CONSULT_PREP_PARALLEL` | 关 | 没有本机 Postgres 中位数,不满足首帧快两成 |
| `MODEL_VERSION_CACHE_TTL_MS` | 0 | 没有量到「准备模型」快一半 |
| `CONSULT_DOMAIN_CONCURRENCY` | 1 | 没有带星盘网络库的镜像重测;起草试算预计达不到快两成五 |
因此没有写 `CHANGELOG.md`,也没有改 `frontend/DESIGN.md`。
## 做了什么
- T1:开流前的登录、注销检查、请求体、会话、模型收进 `loadConsultTurnHead`。并行时注销检查和后面三条同时开始,失败仍按旧的先后返回。423 仍在任何扣点之前。
- T2:并行打开时,审核在开流前启动、在扣点前等待。拦住时不扣点,并且比资料错误优先。关掉时仍是开流前等待审核。
- T3:按模型和版本缓存成功的解析,最长 15 秒;失败和空结果不缓存;管理端清目录缓存时一并清掉。地址检查函数本身没改。已发布版本的供应商代码、类型和地址不可变,但启用开关和密钥仍可变,所以不能超过目录缓存的 15 秒。
- T4:问题保存成功后立刻读标题,和分类同时进行,用到标题时再等。分类仍在扣点和保存之后。
- T5:领域并发开关。为 1 时仍是原来的逐个循环,含时间预算。为 2 时一起算,结果按计划顺序组装,不再逐个套时间预算,领域个数上限不变。
- T6:准备阶段记一条 `consultation-prep-v1`,只有阶段名、毫秒和状态,没有问题原文。
- T7:见 `docs/testing/consult-prep-parallel-ab-20261013.md`。假时钟并行 160、串行 210。这不是开启依据。
- 测试注入:模型目录增加 `setModelCatalogIoForTests`,只给测试替换查库、解密和地址检查。生产路径仍调用原来的函数。
## 改过的既有断言
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `content-moderation.test.ts` | 源码含 `await moderate(visibleQuestion, "input"` 且早于 `reserve_consultation_usage` | `await settleInputModeration()` 早于扣点;准备服务里 `beforeReserve` 早于 `input.reserve()`;拦住时扣点次数是 0 | 审核挪到流内、扣点前。关掉开关时开流前的等待还在 |
| `model-catalog.test.ts` | `return await readVersion(modelId, configVersion)` | `const model = await readVersion(...)`,只有非空才写入短缓存 | 绑版本的解析可以缓存,失败和空结果不写 |
| `model-catalog.test.ts` | 咨询路由里 `if (!sessionModel)` 后面是 503 | 准备函数里 `if (!sessionModel)` 返回 `modelUnavailable()`,状态 503 | 模型不可用仍是开流前的 503,文案随准备步骤搬走 |
| `consult-first-frame-and-pacing-20260928.test.tsx` | 401、请求体、会话不存在、会话类型、模型已变这些字面量在路由的 `runTurn` 之前 | 字面量在 `turn-prep.ts`;路由在 `runTurn` 之前调用 `loadConsultTurnHead` 并返回失败 | 开流前的判断收进同一个函数,状态码仍在开流前 |
| `rectification-session-composer-guard.test.ts` | 咨询路由里会话缺失返回 404,类型不对返回 `session_not_consultation` | 同样两句在 `turn-prep.ts` | 会话判断搬走,文案和状态码没变 |
`consultation-agentic-runtime.test.ts` 的领域循环帮助函数现在也接受异步的假计算,以便断言「后完成的领域仍排在计划后面」。原有断言没有改松。
## 并发为 2 时的时间预算
领域数仍先被原来的上限截断(现在是 2)。开关为 2 时,这两个领域一起开始,不再逐个问「剩下的时间还够不够下一个」。开关为 1 时,原来的逐个判断还在。
## 自测
test:db 未跑:本轮没有改查询语句,任务书也不要求。前端全量和 Python 快速门留给验收。`npm run lint` 0 error,原有 warning 没动。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 0 错 |
| 规范 | `npm run lint --prefix frontend` | 0 error,125 条原有 warning |
| 准备与审核 | `tsx --test` 下列文件:`consult-turn-prep-20261013`、`consult-prep-gate-20261013`、`content-moderation`、`model-catalog`、`consult-first-frame-and-pacing-20260928`、`rectification-session-composer-guard` | 准备函数 3 通过;门禁文件 5 通过;其余在同一次 41 例里通过(当时缓存测试还在改,后来单文件 5 通过、失败 0、取消 0) |
| 计费、注销、标题、寒暄、领域 | 同上命令:`application-billing-contract`、`account-deletion`、`session-title-persist-contract`、`consultation-smalltalk`、`consultation-agentic-runtime` | 136 通过,失败 0,取消 0 |
| 假时钟 | `consult-prep-parallel-ab.mts` | 并行 160,串行 210,同时步数 2 对 1 |
| 领域镜像 | `consult-domain-concurrency-ab.py` | 退出 0,写明没有跑镜像 |
## 第 1 次打回
合并最新 `origin/staging`。业务代码没改,三个开关默认值没改。
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chat-session-authority.test.ts` | 在 `route.ts` 里找 `if (!chatSession)` 后的 404 | 在 `turn-prep.ts` 里锁 `sessionMissing()` 的 404,和分开的 `notConsultation()` 409 | 判定搬走了。仍不许并成一个判断 |
| `consultation-workflow-contract.test.ts` | `beforeReserve: ({ consultationMode }) => createConsultationPlan({`,以及 `reserve: () => reserveConsultationModel(` | `beforeReserve` 的 async 函数体里调用 `createConsultationPlan({`,并且早于 `reserve`;`reserve` 的 async 函数体里调用 `reserveConsultationModel(` | 改成了 async。没有放宽成「文件里出现过函数名」 |
| `consult-prep-parallel-ab.mts` | `frontend/scripts/research/`,从 `frontend/tests` 导入 | `frontend/tests/consult-prep-parallel-ab.mts` | 镜像会复制 scripts、不复制 tests。另一个新增的领域脚本是 Python,不导入测试目录 |
这次自测:上述合同测试加出生时间模式、镜像导入检查,40 通过,失败 0,取消 0。假时钟脚本挪走后仍是并行 160、串行 210。`tsc --noEmit` 0 错。`npm run lint` 0 error,125 条原有 warning。全量 `npm test` 留给验收。`git diff a0e223e2` 到本修复会带上合并进来的测试环境前端文件;相对那次合并的 `origin/staging`,`frontend/src` 没有新增改动。
@@ -0,0 +1,47 @@
# PROGRESS · 发布通路改规则 + 生产首发(2026-10-14)
- 分支:`codex/release-path-production-20261014`
- 基线:从 `origin/staging` 新建,开工 HEAD `f4c53e7f9fad1a3de08b613c3470f8e4a2f62116`。任务书里的 `48a2fadd` 是更早的记录;`74384be0` 是本基线的祖先。
- 开工时测试环境健康检查的版本号与这个 HEAD 相同。收尾时公开健康检查已变成 `cb8004a188afd51ec440a7678f91be801fbd3147`,本分支没有变基到那个提交。本轮没有改测试环境。
- 预检 `scripts/pre_work_check.py`:远端仓库可见,Python 可用。碎片扫描 90 秒超时,聚焦测试 45 秒超时。不能写成预检通过。
- 编号:本树 `docs/BUG_HISTORY.md` 最大号原是 BUG-1346。BUG-1347 在另一条还没合入的分支上,本单用 BUG-1348、BUG-1349,避免以后撞号。
## 做了什么
1. 四个生产工作流共用 `deploy/resolve-release-revision.sh`。发布修订是最近一次代码门禁成功的提交。测试环境头可以在它后面只多文档。回滚仍走原来的确认和祖先检查。
2. 生产环境拒绝测试验证码:环境校验看见这个变量就失败;运行时如果站点是生产域名并且配了验证码,就拒绝启动。
3. 63 个新增迁移的清单、演练脚本、首发步骤和发布后核对都写好了。迁移演练没有单独的 Gitea 按钮,这是决策记录只授权四个工作流之后的做法。
4. 没有点任何生产按钮,没有推 `staging` 或 `main`。
## 自测
| 命令 | 结果 |
| --- | --- |
| `tests/test_resolve_release_revision.py` | 7 通过 |
| `tests/test_rehearse_production_migrations.py` | 3 通过。拒绝非生产路径、拒绝生产库名、计划输出把停止文件留在第二批 |
| `tsc --noEmit -p frontend` | 0 错 |
| `npm run lint --prefix frontend` | 0 error,125 条原有 warning |
| 四个生产工作流 | `js-yaml` 能解析 |
| 新脚本 `bash -n` | 通过(工作流测试里的语法检查那一条通过) |
| 生产发布、恢复点、生产迁移、手动发布门的文本合同 | 通过 |
| 生产站拒绝测试验证码的身份配置测试 | 通过 |
同一批前端测试文件里还有 8 条失败,0 条取消。它们是这台 Windows 机器的环境,不是把断言改弱:
- 环境文件校验要求 Unix 权限 `0600`。Git Bash 里 `chmod 600` 之后 `stat` 仍是 `644`,脚本在看到验证码检查之前就停了。
- 工作流 YAML 检查找到的 `python` 是 Windows 占位程序,退出码 9009。四个生产文件另用项目里的 `js-yaml` 解析通过。
- 两条同步测试缺 `rsync`。
- 一条回滚测试在临时目录上 `install` 改权限被拒绝。
- 纯文档范围脚本在这台机器上没有 `python3`,退出码 49。发布修订测试用仓库里的 Python 跑过了同一条判断。
## 迁移演练(本机一次性库)
空库,角色按主机引导脚本的名字建好,没有真实个人资料。`deploy/rehearse-production-migrations.sh` 在本地模式跑完:
- 停止文件之前 160 个迁移,全部应用后 174 个。
- `other_person_accepted_rows=0`
- 两条标题修补 NOTICE 都是 0
- 网页角色读账户资料、读会话列表:true
- 服务角色读人物表:false,退出码 4
这是预期停止。BUG-1063 的授权不在这 63 个文件里。演练库已删除。在那笔授权进入这一批之前,不要点生产迁移。主机上的正式演练仍由产品按 runbook 做,本机这次不能代替主机恢复点。
@@ -0,0 +1,35 @@
# PROGRESS · 发布通路第 1 次打回(2026-10-14)
- 分支:`codex/release-path-production-20261014`
- 被打回的提交:`00e6efdbfa3d97b0aad764865321bc00fb1cee13`
- 已把当时的 `origin/staging` 合并进来,合并提交 `0dc1d8fa`。没有改写已经推过的历史,也没有变基。
- 没有点任何生产按钮,没有推 `staging` 或 `main`。
## 做了什么
1. 发布修订测试改为用当前 Python。Windows 上如果找到的是系统自带的 bash,就改用 Git Bash。Linux 不再去找写死的 `python.exe`。
2. 主机上的迁移演练:计数、冒烟、删库都在 postgres 容器里做;迁移仍在 web 容器里做。主机上不再调用本机 node。演练库删不掉时退出码不是 0,并写出「演练库未删除,请手动 DROP DATABASE rehearse_…」。本地连接串只要带 `?` 就拒绝。恢复演练库时保留备份里的授权,再把对象属主改成 `schema_owner`。退出码 4 的意思是:演练库跑完迁移后,服务角色仍然不能读人物表。不是去问正在服务的生产库。
3. 生产回滚的控制器改成和正向发布同一条规则:最近一次门禁通过的修订。要回滚的那个版本自己的门禁、必须落在已审查的 main 历史里,这两条没有放宽。
4. 三个生产工作流的合同测试锁住发布门禁开关。生产网址末尾多一个斜杠时,测试验证码仍然拒绝启动。
5. 恢复点工作流没有再改成用门禁产物里的路径清单。原因:改路径清单或纯文档判断脚本本身就会触发门禁,所以测试环境头上的那份和发布修订上的那份是同一份,除非正在发布的就是这两份文件。恢复点只给正在服务的库做快照,不跑迁移、不换镜像。真正改库和换镜像的两步,仍然用发布修订的产物再核对一次。
## 自测
| 命令 | 结果 |
| --- | --- |
| `pytest tests/test_resolve_release_revision.py tests/test_rehearse_production_migrations.py -q` | 12 通过,退出码 0 |
| 两个脚本 `bash -n` | 通过 |
| `tsc --noEmit -p frontend` | 退出码 0 |
| `npm run lint --prefix frontend` | 0 error,123 条原有 warning |
| 身份配置测试 | 9 通过,0 失败,0 取消 |
| 工作流合同测试整文件 | 44 条:39 通过,5 失败,0 取消 |
| `npm run test:db` | 未跑。本轮没有改表 |
整文件里失败的 5 条都是这台 Windows 机器的环境,和上一轮进度里记的是同一类:`python` 占位退出码 9009、缺 `rsync` 两条、临时目录改权限被拒绝、纯文档判断脚本找不到 `python3` 退出码 49。生产部署、恢复点、生产迁移这三条合同测试是通过的,里面包含新加的发布门禁开关和回滚发现模式。部署脚本的语法检查也通过。
## 偏离
- 假主机测试跳过文件锁。这台机器上的 Git Bash 对 `flock -n` 会一直卡住。正式主机仍然要拿到锁,拿不到就退出 75。
- 复制迁移文件改成每个目录一次复制。选中的文件和以前相同。逐个启动复制在这台机器上要几十秒,正式 Linux 主机不受影响。
- `docs/BUG_HISTORY.md` 里本来就有两份标题。合并时保持测试环境那份全文,只在末尾补了 BUG-1348、BUG-1349。没有去重。
- 63 条清单表没有重写。表上方的说明改了一句:服务角色授权在后来的 `20261014010000` 文件里,会进第二批。退出码 4 仍表示演练库跑完后还是没有这项授权。
@@ -0,0 +1,41 @@
# PROGRESS · 发布通路修复单 2(2026-10-14)
- 分支:`codex/release-path-production-20261014`
- issue:#74。先并入当时的 `origin/staging`(合并提交 `d13250f4`),没有改写已推历史。
- 产品选 A:恢复点带上属主和权限,并在本机真实 PostgreSQL 17 上演练。没有点生产按钮。
- 新记录:BUG-1350。不是 BUG-203 的复发。BUG-203 是恢复点锁文件的所有权。
## 做了什么
1. 恢复点导出只保留自定义格式。还原后比较表权限、列权限、表属主,不一致就不写恢复点记录。记录里写 `RECOVERY_FORMAT=2`。创建恢复点的按钮输出仍是原来的 7 行,那个工作流文件没有改。
2. 部署和迁移在允许回退时,读主机上的恢复点记录。版本低于 2 就拒绝,并提示先做新的恢复点。正向部署不要求这份文件。
3. 演练不再改类型属主。迁移前后比较授权,收回了原有授权就退出 1。人物表列级读取权缺了就退出 4。新的一次性库补上迁移角色的连接和建对象权限,因为整库导出带不走数据库本身的权限。
4. 手册的整库还原去掉丢掉属主和授权的参数。还原后补回迁移角色在这个库上的连接和建对象权限。读网站账号之前先设登录身份,否则行级安全会把行藏起来。
## 和任务书不完全一样的地方
- 空库必须留着 `public`。备份是去改它的属主,不是重新创建它。先删掉会停在「schema public 不存在」。
- 数归档会话时,列还不存在不能写在同一条查询里,数据库会在解析阶段就拒绝。先看列在不在,再计数。没有这一列时仍输出 `absent`。
- 本机演练用的是 fixture 里的 PostgreSQL 17,不是生产主机上的那一份备份。生产恢复脚本要求生产目录,不能在这台电脑上原样执行。导出参数、加密方式和还原核对是按脚本重放的。
## 自测
| 命令 | 结果 |
| --- | --- |
| `bash -n` 四个生产脚本 | 退出码 0 |
| `tests/test_rehearse_production_migrations.py` | 6 通过,退出码 0 |
| `tests/test_resolve_release_revision.py` 与上一份一起 | 13 通过,退出码 0(授权那条断言是在这之后加上的,演练测试又单独跑过) |
| `tsc --noEmit -p frontend` | 退出码 0 |
| `npm run lint --prefix frontend` | 退出码 0,0 error,123 条原有 warning |
| `frontend/tests/staging-backend-workflows.test.ts` | 44 条里 39 通过、5 失败、0 取消。失败是这台 Windows 机器的环境:python 退出码 9009、两条缺 rsync、临时目录改权限被拒绝、纯文档范围脚本退出码 49。生产部署、恢复点、生产迁移合同(第 32、34、35、36、37 条)通过 |
| 本机 PostgreSQL 17 演练 | 见 `docs/testing/production-migration-rehearsal-20261014.md`。权限三样一致,`rehearsal_ok=1`,退出码 0,演练库 0 个残留,网站角色读到 1 行 |
## 预检
`scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 退出码 1。远端仓库可见,适配器正常。碎片扫描 90 秒超时,聚焦测试 45 秒超时。这是这台电脑上预检超时的老问题,没有改台账,也不写成预检通过。
## 没做
- 没有点生产按钮,没有连生产主机。
- 没有推 `staging` 或 `main`。
- 没有改测试环境工作流、门禁路径清单、纯文档判断脚本。
+7 -4
View File
@@ -332,8 +332,11 @@
| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` |
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
| `TASK-beta-main-path-20261014.md` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — |
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
| `TASK-beta-main-path-20261014.md` | `PROGRESS-beta-main-path-20261014.md` | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 第 1 次打回后已修,待验收 | `codex/beta-main-path-20261014` |
| `TASK-beta-main-path-fix-20261014.md` | `PROGRESS-beta-main-path-20261014.md` | **#73 第 1 次打回**:`fca62fda` 内容通过;与 #63 删除的旧报告视图有代码冲突,接受删除即可 | 待验收 | `codex/beta-main-path-20261014` |
| `TASK-release-path-production-20261014.md` | `PROGRESS-release-path-production-20261014.md` | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 第 2 次打回后续修 | `codex/release-path-production-20261014`(BUG-1348、BUG-1349) |
| `TASK-release-path-production-fix-20261014.md` | `PROGRESS-release-path-production-fix-20261014.md` | **#74 第 1 次打回**:发布规则成立;测试写死 Windows 路径(Linux 全红)、主机演练依赖主机 node 且会留下生产数据副本、docs-only 头时回滚被拒;另补 CHECK_RELEASE_GATE 断言与 OTP 守卫规范化 | 已修,第 2 次仍未过 | `codex/release-path-production-20261014` |
| `TASK-release-path-production-fix2-20261014.md` | `PROGRESS-release-path-production-fix2-20261014.md` | **#74 修复单 2(产品选 A)**:恢复点要带属主与权限,旧格式不得回退;演练按备份里的授权比对,并在真实 PostgreSQL 17 上跑通一次 | 待验收 | `codex/release-path-production-20261014` |
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
| `TASK-beta-capacity-20261014.md` | — | **内测容量**:按用户限流、重计算短排队与对话重试、兑换码当邀请码、staging 10/20 并发压测。**等 #58 合入后开工,压测等 #57 合入** | 待前置 | — |
| `TASK-special-lagnas-native-20261008.md` | `PROGRESS-special-lagnas-native-20261008.md` | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 已验收(已部署 staging `795b8845`,BUG-1273) | `codex/special-lagnas-native-20261008`(未上线;部署以 `/api/health` 为准) |
@@ -475,8 +478,8 @@
| `TASK-api-disk-cache-retention-20261013.md` | `PROGRESS-api-disk-cache-retention-20261013.md` | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待验收 | `codex/api-disk-cache-retention-20261013` |
| `TASK-report-reference-perf-20261013.md` | `PROGRESS-report-reference-perf-20261013.md` | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待验收 | `codex/report-reference-perf-20261013` |
| `TASK-report-reference-perf-fix-20261014.md` | `PROGRESS-report-reference-perf-20261013.md` | **#61 第 1 次打回**:`69c739fb` 本单全过;与清理规则合并后,长报告缓存目录补进自行清理名单 | 待验收 | `codex/report-reference-perf-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-fix-20261014.md` | — | **#62 第 1 次打回**:`a0e223e2` 业务逻辑通过;两条源码文本断言未跟改(§7.8)、A/B 脚本从 `frontend/tests` 导入(BUG-1140) | 待领取 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | `PROGRESS-consult-prep-parallel-20261013.md` | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-frontend-dead-code-20261013.md` | `PROGRESS-frontend-dead-code-20261013.md` | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-frontend-dead-code-fix-20261014.md` | `PROGRESS-frontend-dead-code-20261013.md` | **#63 第 1 次打回**:类名白名单去掉已删组件留下的条目 | 待验收 | `codex/frontend-dead-code-20261013` |
| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` |
@@ -0,0 +1,36 @@
# TASK · 内测主路径修复单(issue #73,第 1 次打回:与 #63 代码冲突)
- 日期:2026-10-14
- 原任务书:`docs/tasks/TASK-beta-main-path-20261014.md`
- 被验 SHA:`fca62fda43b3f8b7bb76303bf0c83427d25c63ee`(`codex/beta-main-path-20261014`)
- 执行方:Grok,同一分支续做(merge 最新 `origin/staging`,不改写已推历史)
- BUG 编号:沿用 BUG-1063 等本单编号
## 1. 验收结论:内容通过,与 staging 合并有代码冲突
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 迁移 `20261014010000_chart_profiles_service_role_select.sql` | 通过 | 只加逐列 SELECT,列与 `CHART_SUBJECT_SELECT` 一致;不授写、不改 RLS、不动 authenticated;只增不删,对已部署代码向后兼容(§7.6) |
| `test:db` | 以 Grok 为准 | 82 项 / 失败 13,与 `windows-known-failures.txt` 逐名相同 |
| AI 生成标识 | 通过 | 输入框下一行、报告标题下一行;英文句只锁不显示 |
| 状态板清账 | 通过 | 写「已部署 staging `xxxx`」的 11 个提交号,我逐个核对都在 `origin/staging` 上;未把任何一条标成 resolved |
| 与最新 staging 合并 | **未通过** | `CONFLICT (modify/delete): frontend/src/components/personal-report/personal-report-document-view.tsx deleted in origin/staging and modified in HEAD` |
**原因**:#63(BUG-1342,已在 staging)把从不挂载的旧报告视图 `personal-report-document-view.tsx` 整个删了;本单给它的 `ReportCover` 加了一行 AI 标识。两单同时在做,不是你的疏漏。
## 2. 修法要求
1. `git merge origin/staging`,冲突里**接受删除**(`git rm frontend/src/components/personal-report/personal-report-document-view.tsx`)。现行报告页是 `personal-report-markdown-view.tsx`,那里的标识已经加上,不需要再找新位置。
2. `frontend/tests/ai-generated-notice-20261014.test.ts` 里若有读取 / 断言该已删文件的,删掉那部分,按 §7.3 写三栏;markdown 视图、输入框的断言保留不动。
3. `CHANGELOG.md`、`docs/BUG_HISTORY.md` 的文档冲突两边都保留。
4. 合并后在本机跑:`tsc --noEmit`、`npm run lint`、`npx tsx --test tests/ai-generated-notice-20261014.test.ts tests/runtime-sources-no-test-imports.test.ts tests/class-name-definition-contract.test.ts`,fail 与 cancelled 都为 0。
5. 业务代码除上面这处外不改。PROGRESS 末尾记一行。
## 3. 验收标准
- `git merge-base --is-ancestor origin/staging <新 SHA>` 成立;
- Claude 前端全量 `npm test` 失败清单与 staging 基线逐名相同。
## 4. 让步顺序
无。第 1 次打回。
@@ -0,0 +1,52 @@
# TASK · 发布通路修复单(issue #74,第 1 次打回)
- 日期:2026-10-14
- 原任务书:`docs/tasks/TASK-release-path-production-20261014.md`(产品负责人 2026-10-14 在 issue #74 再次确认授权修改四个生产工作流)
- 被验 SHA:`00e6efdbfa3d97b0aad764865321bc00fb1cee13`(`codex/release-path-production-20261014`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- 红线不变:仍只改原任务书授权的四个工作流;不改 staging 工作流、`gated-paths.txt`、`is-docs-only-range.sh`;不点任何生产按钮
## 1. 验收结论:发布规则本身成立,三处阻断首发
已确认成立(不需要改):正向路径要求 R 有精确 SHA 的 staging push 门禁、R 或 H 有手动发布门禁、main == staging、R..H 只含文档;变更前复核用的是 R 门禁产物里的 `is-docs-only-range.sh` 与 `gated-paths.txt`;staging 工作流零改动;`AGENTS.md` 只动了授权的 §2.4、§2.5、§2.7。
### B1 · Python 测试在 Linux 上全红(会让发布门禁红)
`tests/test_resolve_release_revision.py` 第 13–14 行写死 `PYTHON = ROOT/".venv"/"Scripts"/"python.exe"` 和 `D:\Program Files\Git\bin\bash.exe`。Claude 在 Linux 全量 pytest:该文件 7 条里 6 条失败(`python.exe: No such file or directory`)。`release-quality-gate.yml` 跑全树 pytest,runbook 第 3 步会红。
- 修:用 `sys.executable`;bash 用 `shutil.which("bash")`,Windows 再回落到 Git Bash。Linux 与 Windows 都要过。
### B2 · 主机模式的迁移演练跑不通,且会在生产库上留下一份生产数据副本
`deploy/rehearse-production-migrations.sh`:
- `run_node_sql`(约 88–137 行)在**主机**上跑 `node` + `import pg`,连 `@postgres:5432`(约 253 行)——这个主机名只在 Docker 网络里有;生产主机按规矩不装 node / `node_modules`、不构建。计数步骤失败退出 1,清理步骤(约 142 行)也用同一个函数,同样失败只打警告 → `rehearse_<时间戳>` 库连同恢复出来的生产数据留在生产 PostgreSQL 上,与 runbook「演练库会删掉」矛盾。
- 恢复用 `--no-owner --no-acl`,对象归 `POSTGRES_USER`,而迁移以 `role=schema_owner` 执行,`ALTER` 可能报 "must be owner";ACL 没恢复,第 316–325 行的权限检查看到的不是生产真实授权,「退出 4 = BUG-1063 授权缺失」不可靠。
- 修:
1. 主机模式下所有 SQL 都走 `docker compose exec` / `docker run` 进 postgres 或 web 容器执行(与第 271 行 `--entrypoint node` 一致),不依赖主机 node。
2. 清理放 `trap`,并且**清理失败要非零退出并大字提示「演练库未删除,请手动 DROP DATABASE rehearse_xxx」**,不得只打警告。
3. 恢复后先把对象属主改成 `schema_owner`(或以 restore 后的属主执行迁移),并说明权限检查的口径;若做不到「看到生产真实授权」,就把「退出 4」的含义在脚本头和 runbook 里改成如实描述。
4. 本地模式的连接串拒绝带 `?` 查询参数(`?database=` / `?host=` 能覆盖库名守卫)。
5. runbook 第 5 步列的通过标准(归档会话前后数、自镜像前后数、NOTICE 计数)脚本并不打印:要么脚本打印,要么 runbook 删掉,二者必须一致。
- 验收:新增或扩展 `tests/test_rehearse_production_migrations.py`:主机模式不调用主机 `node`(用假 `docker` 记录调用);清理失败时退出码非零;带查询参数的 URL 被拒。
### B3 · runbook 的失败回退走不通
- `deploy-production.yml` 回滚分支(约 126–137 行)仍要求 main 头 H 有一次 staging push 门禁成功记录;而本单要解决的正是「H 只多了文档、没有门禁记录」的情况——此时 `first` 返回 null、`jq -e` 失败,runbook 约 107 行依赖的回滚会被拒。
- 修:回滚分支对 controller 的门禁查找改用与正向相同的规则(`resolve-release-revision.sh` 解析出的 R),或在 H 无门禁时用 R 的门禁产物;回滚的「目标 SHA 必须在已审 main 历史里」等原有检查不放宽。加合同测试锁住。
- runbook 约 106 行「从恢复点整库恢复」没有给出工具和步骤:写清用哪个脚本 / 命令、需要哪个文件(`recovery_reference` 结尾就是备份文件名)、恢复后怎么核对。
## 2. 非阻断但这轮一并改
1. `frontend/tests/staging-backend-workflows.test.ts`:迁移与恢复两条用例把工作流里的门禁断言换成了检查脚本文本,**没有任何断言锁住三个工作流里的 `CHECK_RELEASE_GATE=true`**——删掉它会让正向路径失去发布门禁要求而测试仍绿。补上这三处断言(三栏写明)。
2. `frontend/src/modules/identity/config.ts` 约 101 行用原始字符串比 `AUTH_USER_ORIGIN`,`https://jyotisha.chat/`(带斜杠)会漏过「生产拒绝测试验证码」。改为与 `readOrigin` 规范化后的值比较,并加一条带斜杠的测试。
3. `resolve-release-revision.sh` 的正向判定用的是 H 自己检出里的 `gated-paths.txt`;虽然变更前有 R 产物复核兜底,但 recovery 工作流没有复核。请在 recovery 的判定里同样用 R 产物(或在 PROGRESS 写明为何恢复点可以接受)。
## 3. 验收标准
- Linux:`python3 -m pytest tests/test_resolve_release_revision.py tests/test_rehearse_production_migrations.py -q` 全绿;Claude 全量 pytest 与 staging 基线逐条比对无新增;前端全量 `npm test` 无新增。
- `bash -n` 全部脚本通过。
- runbook 每一步与工作流 / 脚本输出逐条一致(Claude 逐条对照)。
- 仍未点任何生产按钮。
## 4. 让步顺序
B1、B2、B3 都不可让;第 2 节第 1、2 条不可让,第 3 条可写理由不改。
@@ -0,0 +1,76 @@
# TASK · 发布通路修复单 2:恢复点要带权限(issue #74,第 2 次未过后产品选 A)
- 日期:2026-10-14
- 前序:`TASK-release-path-production-20261014.md`、`TASK-release-path-production-fix-20261014.md`
- 被验 SHA:`32aa46b5731a32b9d7760738f51b983ab4dea041`(`codex/release-path-production-20261014`)
- 执行方:Grok(Windows + Docker),同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- **决策记录**:产品负责人 2026-10-14 在 issue #74 选 **A**——**授权扩大范围修改生产备份工具 `deploy/run-production-recovery.sh`**,让恢复点带上属主与权限,并要求一次在真实 PostgreSQL 17 上的端到端演练。原有授权(四个生产工作流)不变;仍不改 staging 工作流、`gated-paths.txt`、`is-docs-only-range.sh`;仍不点任何生产按钮。
## 1. 上一轮结论
已修好:B1(测试跨平台)、主机模式不依赖主机 node、清理失败非零退出并提示手动删库、库名守卫、本地 URL 拒绝查询参数、runbook 第 5 步与脚本输出一致、docs-only 头时回滚不再被拒且原有回滚保护不少、OTP 守卫按规范化 origin 比较、三处 `CHECK_RELEASE_GATE=true` 断言、recovery 用 H 的门禁路径清单的理由成立。
仍未通过,根子是同一个:
1. `deploy/run-production-recovery.sh` 第 114 行 `pg_dump ... --no-owner --no-acl`:**恢复点不含属主、不含任何 GRANT / 默认权限**。第 130 行的还原核对同样 `--no-owner --no-acl`,只数行数,所以「核对通过」看不出权限丢了。
2. 后果 A(回退):runbook 约 106–114 行照此整库还原后,网页层切到 `authenticated` / `service_role` 执行查询(`frontend/src/lib/db/local-postgres-client-core.ts` 约 377 行),对所有表无权限,整站请求被拒;四个计数却全部对得上,看不出来。之后以 `schema_owner` 跑迁移也会 "must be owner"。
3. 后果 B(演练):`deploy/rehearse-production-migrations.sh`
- 约 351–360 行改属主的 DO 块遍历 `pg_type` 里 `typtype IN ('e','c','d')`,`'c'` 包含每张表的行类型,PostgreSQL 对它报 "is a table's row type",`ON_ERROR_STOP` 直接中止;约 332–333 行改序列属主在序列先于所属表出现时也会被拒。
- 备份里没有 ACL,约 316 行还原后权限烟测看到的不是生产真实授权(`has_table_privilege` 不看列级授权),永远过不了,演练打不出 `rehearsal_ok=1`。
- 脚本头约 10 行、runbook 约 70 行说授权「来自备份里原来的授权 + 迁移」,与事实不符。
- 模拟 docker 的测试抓不到以上任何一条。
## 2. 任务
### T1 · 恢复点带属主与权限(`deploy/run-production-recovery.sh`)
- `pg_dump` 去掉 `--no-owner --no-acl`(保留 `--format=custom`)。角色是集群级对象,同一 PostgreSQL 实例内还原时 `schema_owner`、`authenticated`、`service_role` 等都已存在。
- 还原核对改为不带 `--no-owner --no-acl`,以 `POSTGRES_USER` 执行;在原有四个计数之外,**加权限核对**:对 `public` 下每张表比较源库与还原库的
`select grantee, privilege_type, table_name from information_schema.role_table_grants where table_schema='public'`
和列级 `information_schema.column_privileges`、以及 `pg_class.relowner` 的属主名,**三者必须完全相同**,不同即失败、不写 `recovery-baseline.env`。核对结果写进 `*-restore-verify.json` 的 `checks`(只写计数与是否一致,不写任何数据内容)。
- `RESTORE_VERIFIED=true` 只有在权限核对也通过时才写。
- 旧格式(8 月以来、不带权限)的恢复点**不得**再被当作可回退的恢复点:在 `recovery-baseline.env` 写一个格式版本号(如 `RECOVERY_FORMAT=2`),deploy / migrate 工作流读取恢复点时要求版本 ≥ 2,否则拒绝并提示先点 Create production recovery。合同测试锁住。
### T2 · 演练脚本按真实权限工作(`deploy/rehearse-production-migrations.sh`)
- 还原不再加 `--no-owner`(备份已带属主),**删掉**改属主的 DO 块与序列改属主循环;若仍需兜底,只能用 `ALTER TABLE ... OWNER`(对表)且不得遍历表的行类型。
- 权限烟测改为比较「迁移前还原库的授权」与「迁移后」——迁移不应收回任何已有授权;再检查本批迁移新增的授权确实存在(含 BUG-1063 的 `service_role` 对 `chart_profiles` 逐列 SELECT)。退出码含义如实写进脚本头与 runbook。
- runbook 第 5 步加一句:演练结束后执行 `\l rehearse_*` 确认没有残留库。
### T3 · runbook 的整库还原(`docs/operations/production-release-runbook-20261014.md`)
- 还原命令去掉 `--no-owner --no-acl`;写出删库重建的具体命令(只针对 `jyotisha`,`WITH (FORCE)` 前先停 `api`、`web`)。
- 还原后核对除四个计数外,加上与 `*-restore-verify.json` 里权限核对同口径的检查,**并实际以 `authenticated` 身份读一行**(如 `set role authenticated; select 1 from public.profiles limit 1;`)验证网站账号能读。
- 只允许使用 `RECOVERY_FORMAT=2` 的恢复点。
- 回退目标 `7b620c7a` 需要一次成功的手动发布门禁记录:runbook 写明首发前先确认 Gitea 里该记录仍在(runs 列表里能查到);查不到就先停,问产品负责人。
### T4 · 在真实 PostgreSQL 17 上端到端演练一次(必做,不得只跑模拟测试)
在 Grok 本机 Docker 里:
1. 用 `npm run test:db` 同一个 PG17 fixture 建库,按 `frontend/supabase/migrations/` 应用到生产当前版本对应的迁移(以 `docs/operations/production-migrations-20261014.md` 里「生产已有」的最后一条为止),写入少量**虚构**数据(每张关键表几行)。
2. 用新 `run-production-recovery.sh` 的同款 `pg_dump` 参数导出、加密(本地测试密钥),按新还原核对流程还原并比对权限——必须一致。
3. 用 `rehearse-production-migrations.sh` 本地模式对这份备份演练全部待上线迁移,必须打出 `rehearsal_ok=1`,且演练库被删除。
4. 再按 runbook T3 的整库还原步骤还原到一个新库,以 `authenticated` 读一行成功。
5. 全过程命令与输出摘要(只写计数、退出码、`rehearsal_ok`,不写数据内容与任何密钥)写进 `docs/testing/production-migration-rehearsal-20261014.md`。
### T5 · 小项
- `frontend/tests/staging-backend-workflows.test.ts` 约 1157 行:正向路径的 `CHECK_RELEASE_GATE=true` 正则会同时匹配回滚那一行,删掉正向那处仍会绿;把它锚在正向调用(例如带 `>>"$GITHUB_OUTPUT"` 的那一行)。
- 记录:`docs/BUG_HISTORY.md` 新增一条「生产恢复点不含属主与权限,整库还原后网站账号无权限」(状态写 fixed-pending-verify;复发 / 关联写清楚 8 月的恢复点工具),PROGRESS 记录本轮。
## 3. 硬红线
1. 不点任何生产按钮;不连任何生产主机;所有演练只用本机 Docker 和虚构数据。
2. 备份与核对文件里不得写入任何数据内容、密钥、口令。
3. 不改 staging 工作流、`gated-paths.txt`、`is-docs-only-range.sh`;生产工作流的改动只限 T1 要求的「恢复点格式版本」检查。
4. 改既有断言写三栏。
## 4. 验收
- Linux:`python3 -m pytest tests/test_resolve_release_revision.py tests/test_rehearse_production_migrations.py -q` 全绿;Claude 全量 pytest、前端全量 `npm test` 无新增失败;`bash -n` 全部脚本通过。
- `docs/testing/production-migration-rehearsal-20261014.md` 有 T4 的真实 PG17 运行记录:权限比对一致、`rehearsal_ok=1`、演练库已删、`authenticated` 读一行成功。Claude 会核对记录与脚本输出格式一致;必要时要求附上脱敏后的完整输出。
- runbook 每一步与脚本、工作流逐条一致。
## 5. 让步顺序
无。T1–T4 都是首发前提。
+3 -3
View File
@@ -8,11 +8,11 @@
| 1 | 新人第一次进站 | 一个从没用过的邮箱;手机 Safari | 打开登录页 → 勾选同意 → 输入邮箱 → 发验证码 → 输入固定码 → 填出生资料(城市、日期、时间) | 不要求设密码;建盘后先看到群星汇聚过场,**不先弹兑换窗**;过场后进入首页 | BUG-1080、1152;`staging-otp-skip-password-20261001.md`、`birth-sky-cover-checklist.md` | | | |
| 2 | 首页 | 第 1 条的账号 | 停留首页、刷新一次 | 不卡在「正在载入账户」;今日星语有当天内容(不是「没写出来」);会话列表正常 | BUG-936、283 / 299;`home-first-load-20260930.md`、`session-list-rebuild-20260921.md` | | | |
| 3 | 寒暄不扣点 | 记下当前点数 | 新对话发「你好」 | 一句简短回应,不排盘、不出长解读;点数不变 | BUG-976、977;`consult-smalltalk-fastpath-20260920.md` | | | |
| 4 | 普通对话 + AI 标识 | 有点数 | 问一个事业问题,再追问一句 | 回答几段人话、不念内部清单;输入框下方有「回答由 AI 生成,仅供参考……」一行;iPhone 上正文右侧无大块空白、不抖;收起键盘页面不上移 | BUG-1252、1253、1255、920、内测主路径单 T2;`consult-readable-20261006.md`、`mobile-viewport-scroll-lock-20260917.md` | | | |
| 4 | 普通对话 + AI 标识 | 有点数 | 问一个事业问题,再追问一句;再打开一条生时校正看输入框 | 回答几段人话、不念内部清单;两处输入框下方都有「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」,窄屏不换行、不挡住发送;iPhone 上正文右侧无大块空白、不抖;收起键盘页面不上移 | BUG-1252、1253、1255、920、1347、内测主路径单 T2;`consult-readable-20261006.md`、`mobile-viewport-scroll-lock-20260917.md` | | | |
| 5 | 明说要校正 | 档案出生时间是「大约」 | 新对话说「帮我做一下生时纠正」;另开一个对话说「这个解读我不认同」 | 第一句直接进入校正,不先写解读;第二句**不**进入校正,只改读法 | BUG-1306、1317;`rectification-explicit-entry-20261012.md` 第 1、1b、4、9 步 | | | |
| 6 | 校正开场 | 第 5 条进入的校正 | 看开场三句 | 说出填的时间离最近换段差几分钟、两边哪几件事不同;离得近时有「这里连本命上升都换」;不出现秒、「记录时间是错的」 | BUG-1307、1309;`rectification-nearest-boundary-opening-20261012.md` | | | |
| 7 | 人物档案不串人 | 人物档案里加一位家人 | 切换到家人 → 设为默认 → 回到本人 → 再切家人 | 每次显示的资料都是对应的人,名字、出生资料不串 | BUG-1030;`people-archive-p1-20260924.md` | | | |
| 8 | 给别人出报告 | 第 7 条的家人;有点数 | 选家人 → 生成报告 → 等完成 → 打开 | 报告能生成完成(不一直排队、不失败退款),标题下有「本报告由 AI 生成,仅供参考。」 | BUG-1063、内测主路径单 T1/T2 | | | |
| 9 | 本人报告 | 本人 | 生成一份中文完整报告并打开;切英文看一眼 | 开篇重点卡正常;英文版无中文残留;结尾免责在 | BUG-1312~1316;`report-translation-gaps-20261013.md`、`report-focus-card-20261013.md` | | | |
| 8 | 给别人出报告 | 第 7 条的家人;有点数 | 选家人 → 生成报告 → 等完成 → 打开 | 报告能生成完成(不一直排队、不失败退款),标题下有「本报告由 AI 生成,仅供参考。」 | BUG-1063、1347、内测主路径单 T1/T2 | | | |
| 9 | 本人报告 | 本人 | 生成一份中文完整报告并打开;切英文看一眼 | 开篇重点卡正常;中文标题下有「本报告由 AI 生成,仅供参考。」,英文标题下是 “This report is AI-generated and for reference only.”;英文版无中文残留;结尾免责在 | BUG-1312~1316、1347;`report-translation-gaps-20261013.md`、`report-focus-card-20261013.md` | | | |
| 10 | 反馈与注销 | 任一测试账号 | 对一条回答点踩并写一句反馈 → 后台「反馈」页查看;账户设置里申请注销 → 再撤销 | 后台能看到反馈;注销申请显示 7 天冷静期,撤销后账户恢复 | `surface-feedback-20260929.md`、`legal-consent-20260930.md`、`compliance-launch-20260930.md` | | | |
| 11 | 限流与排队(容量单合入后) | 两台设备同一账号 | 1 分钟内连发 7 次提问;另找 3 个账号同时做校正 | 第 7 次得到一句「太快了,稍等一下」类提示,不扣点;同时校正的人看到「排一下队」后照常继续,不报错 | 内测容量单 T1/T2 | | | |
@@ -0,0 +1,36 @@
# 发问准备并行:本轮量到的和没量到的(2026-10-14)
开关都还关着。用户发问后的第一行,这轮不会变快。
## 进程内假时钟
命令:`node node_modules/tsx/dist/cli.mjs tests/consult-prep-parallel-ab.mts`(在 `frontend` 目录)。
假延迟:注销检查 50 毫秒,读会话 80 毫秒,解析模型 80 毫秒。这不是数据库实测。
| | 逻辑耗时 | 同时进行的步数 |
| --- | --- | --- |
| 串行 | 210 | 1 |
| 并行 | 160 | 2 |
并行比这三条假延迟的相加更短。它不能代替「首帧中位数下降两成」的开启条件,因为本机没有量 Postgres。
审核密钥不在这台机器上。测试里把审核挂起 1500 毫秒,第一小段在 400 毫秒内已经写出,然后才收到拦截。这是假等待,不是供应商的真实时间。
## 版本缓存
行为测试:15 秒内第二次解析不再查库、不再做地址检查;清缓存后会再查;缓存关掉后每次都查;查库失败和空结果不会被记住。没有量到真实「准备模型」那一段快了多少,所以缓存时间保持 0。
## 两个领域一起算
命令:`.venv\Scripts\python.exe frontend\scripts\research\consult-domain-concurrency-ab.py`
本轮没有启动计算服务,也没有带星盘网络库的镜像。任务书起草时的试算大约只快一成,热缓存时反而更慢,达不到快两成五。默认仍是一个一个算。
## 仍然关闭的原因
| 开关 | 现在 | 要打开还缺什么 |
| --- | --- | --- |
| 发问准备并行 | 关 | 本机 Postgres 上鉴权、注销检查、读会话、读模型各 30 次的中位数,并且首帧中位数至少快两成 |
| 模型版本缓存 | 0 毫秒 | 同上,准备模型那一段至少快一半 |
| 两个领域一起算 | 1 | 带星盘网络库的镜像上墙钟至少快两成五,争用时 429 不增加,响应除约定字段外一致 |
@@ -0,0 +1,20 @@
# 生产首发后核对(2026-10-14)
产品发布完成后照着看。把「实际」一栏填成通过或没通过。不要把登录账号、页面正文、出生资料写进仓库。
`R` 是这次部署填的那个 40 位提交,也就是测试环境健康检查在发布前显示的版本。
| 核对 | 预期 | 实际 |
| --- | --- | --- |
| 打开 `https://jyotisha.chat/api/health` | 状态 200,`deployment.gitCommit` 等于 `R`,`apiGitCommit` 等于 `R` | |
| 同一份健康检查里的数据库 | `database.requiredMigrationsPresent` 为 true,`latestMigration` 与测试环境同一字段相同 | |
| 内部健康检查 | 状态 200,`swisseph_available` 为 true | |
| 打开 `/login` | 状态 200,页面数据里的 `testOtp` 是 null | |
| 未登录打开 `/api/account` | 状态 401 | |
| 用受控账号登录 | 能登录 | |
| 登录后建一张盘 | 盘能打开 | |
| 发一轮对话 | 能收到回复 | |
| 打开一份报告 | 报告页能打开 | |
| 打开 `/admin` | 只有管理员能进,普通账号不能 | |
任何一行没通过:不要继续开放内测。按 runbook 的「失败后」做整库还原,并把应用退回 `7b620c7a`。
@@ -0,0 +1,65 @@
# 生产迁移演练记录(2026-10-14)
## 本机 PostgreSQL 17
本机 Docker,镜像 `postgres:17-alpine`,和 `npm run test:db` 用的是同一个 fixture。没有连生产主机,没有点生产按钮。数据是虚构的,每种关键表一行。下面只记计数、退出码和是否一致。
1. 建库后套用生产当前版本:`frontend/db/migrations` 全部,加上 `frontend/supabase/migrations` 里文件名早于 `20260816010000` 的文件。Windows 上有 4 个同名文件,内容一致,只留一份。
2. 用恢复脚本同款方式导出:`pg_dump --format=custom`。再用 openssl `aes-256-cbc -salt -pbkdf2` 加密,然后解密。密钥没有写进仓库。
3. 新建空库,`pg_restore --exit-on-error`。不先删 `public`。用超级用户比较源库和还原库的表权限、列权限、表属主。
4. 再还原一份,跑 `deploy/rehearse-production-migrations.sh` 的本地模式。
5. 再按手册还原一份。先把登录身份设成库里已有的一条账户,再 `set role authenticated`,读 `public.profiles`。
| 项 | 值 |
| --- | --- |
| 基线迁移文件 | 111 |
| 基线迁移退出码 | 0 |
| 基线已应用 | 111 |
| 源库计数(身份账户 / 账户资料 / 点数流水 / 人物表 / public 表) | 1 / 1 / 1 / 1 / 76 |
| 加密退出码 | 0 |
| 解密退出码 | 0 |
| 表权限一致 | true(871 行) |
| 列权限一致 | true(6760 行) |
| 表属主一致 | true(75 行) |
| 演练第一批文件 | 160 |
| 演练全部文件 | 175 |
| 演练退出码 | 0 |
| archived_sessions_before | absent |
| archived_sessions_after | 0 |
| self_chart_rows_before | 0 |
| self_chart_rows_after | 0 |
| other_person_accepted_rows | 0 |
| notice_lines | 161 |
| revoked_grants | 0 |
| chart_profiles_service_role_select_missing | 0 |
| rehearsal_ok | 1 |
| 结束后名字以 rehearse_ 开头的库 | 0 |
| authenticated 读到的行 | 1 |
`notice_lines=161` 是这次空库演练里迁移提示的行数,不是生产上的标题修补人数。生产上仍按手册:标题修补不是 0 就停。
## 主机演练(产品填写)
产品在主机上跑完 `deploy/rehearse-production-migrations.sh` 后填写。只写行数和通过/停止。不要写姓名、出生时间、邮箱、会话正文、密钥或任何一行的内容。
| 项 | 结果 |
| --- | --- |
| 日期 | |
| 发布修订 R | |
| 恢复点引用 | |
| 一次性库名字(必须是 `rehearse_` 开头) | |
| `other_person_accepted_rows` | 预期 0 |
| 归档会话:迁移前非空行数 / 迁移后 | 迁移后应为 0 |
| 人物表里本人镜像:迁移前 / 迁移后 | 迁移后应为 0 |
| 标题修补两条 NOTICE | 生产上预期都是 0。不是 0 就停止 |
| 自动标题回填 NOTICE | 只写数字 |
| 未知时间改成时段比较的行数 | |
| 家人整点改为已采用的行数 | |
| 时段回填的行数 | |
| 无日期证据改为已确认的行数 | |
| 网页角色读账户资料 | true 才算过 |
| 网页角色读会话列表 | true 才算过 |
| 服务角色读人物表 | true 才算过。false 时脚本退出码 4,停止,不要点生产迁移。这是演练库里的结果,不是正在服务的生产库 |
| 最终一行 | `rehearsal_ok=1` 才算演练通过 |
停止时:生产库不应被修改。把脚本打印的那一句原因贴在这里,去掉任何连接地址。
+2 -2
View File
@@ -339,11 +339,11 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
### Input and composer
- **Structure:** a column, not a row — warm canvas field on top, the composer's own bottom row beneath it. The row carries the model picker on the left and the send/stop control on the right, with the character count inline beside it.
- **No strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has.
- **No tool strip below the composer.** `.composer-footer` used to sit under the field at a permanent `min-height: 44px`, holding the model picker and a count that only appears near the cap — so most of the time it was 44px of empty chrome on every screen. The row is inside the box now and only renders what it has. A single line of copy (`.composer-ai-notice`, 2026-10-14) sits under the box: it is not a tool row, has no 44px slot, and on a narrow screen it stays one line and ellipsizes instead of wrapping over the send control.
- **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open, **a rectification session whose surface is not yet open**), invalid. Generating does not disable the textarea; the send control becomes stop. On a rectification session the ordinary composer is only a disabled field with placeholder「正在打开生时校正…」; if opening failed, a「重新打开生时校正」button sits above it. No spinner.
- **Queue:** one pending card (`.composer-queue`) sits above the field while an answer is in flight. Copy: “已排队,回答结束后发出”, plus the text and a 44px “撤回”. A second Enter appends to the same card with a newline. Successful settlement sends it; stop, failure, or recovery puts the text back in the field. The send button is disabled while a card is waiting. No spinner.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot; nothing is stacked below it.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`, which the composer renders inline and only once `characterRemainingVisible()` is true — it no longer has a permanent container. Controls that belong to the composer go through the `toolbar` slot. The only thing under the box is the one-line AI notice, not another control.
- **Inner field corners:** the textarea inside `.composer` has `border-radius: 0`. Rounding stays on the outer `.composer` only. The field has no horizontal padding, so a radius on the textarea itself clips the first glyph's left edge (BUG-1311).
### Answer start anchor
+6
View File
@@ -239,6 +239,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
星盘、星历、报告列表几秒内就会出现,等待时只放轨道环动画,不在屏幕上写「这一张盘还没拿到。」一类句子;这些句子只给读屏器。首页不再写「上次那次校正还没完成,可以在历史对话里接着做。」。
## AI 生成标识(2026-10-14)
对话输入框下方一行,普通对话和生时校正共用,不进回答正文:「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」英文壳还没有,对应英文先定下:「AI-generated. For reference only; not medical, legal or financial advice.」
报告标题下(中文阅读页、结构化阅读页、英文版)一行:「本报告由 AI 生成,仅供参考。」英文:「This report is AI-generated and for reference only.」报告结尾原有的免责保留,不改生成正文。
## 协议与同意(2026-09-30)
- 勾选框:「我已阅读并同意《用户协议》和《隐私政策》」;未勾选就提交:「请先阅读并勾选同意,再继续。」(只在框下一行,不弹 toast)。
@@ -0,0 +1,24 @@
"""Two-domain concurrency bench.
The vedastro image and the local API were not started in this round.
The task book's draft on one public chart was about 10 percent faster when
cold and slower when the chart was already cached. The switch opens only
when the vedastro image is at least 25 percent faster, contention 429s do
not rise, and the byte diff matches. Default stays 1.
"""
from __future__ import annotations
def main() -> int:
print("CONSULT_DOMAIN_CONCURRENCY default stays 1")
print("vedastro image bench: not run")
print("local API bench: not run")
print("contention 429 count: not run")
print("byte diff: not run")
print("reason: this round has no vedastro measurement; the published draft missed the 25 percent gate")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+158 -88
View File
@@ -10,7 +10,15 @@ import {
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { consultDomainConcurrency, consultPrepParallelEnabled, modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
import {
ConsultationInputBlockedError,
consultPrepObservabilityEvent,
type ConsultPrepPhase,
} from "@/lib/consult/prep-log";
import { loadConsultTurnHead } from "@/lib/consult/turn-prep";
import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
import type { ModerationResult } from "@/lib/moderation";
import { recordModerationEvent } from "@/lib/moderation/log";
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
@@ -304,78 +312,43 @@ export async function POST(request: Request) {
return jsonForSupabaseSetupFailure(error, "POST /api/consult");
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
const prepParallel = consultPrepParallelEnabled();
const prepPhases: ConsultPrepPhase[] = [];
const head = await loadConsultTurnHead({
parallel: prepParallel,
getUser: () => supabase.auth.getUser(),
readPendingDeletion: (userId) => refuseWhenAccountDeletionPending(userId),
readBody: async () => chatRequestSchema.safeParse(await request.json().catch(() => null)),
readSession: async (userId, parsed) => await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.sessionId)
.eq("user_id", userId)
.maybeSingle(),
resolveModel: async (chatSession) => {
if (!chatSession.model_id) return null;
return await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
},
});
if (!head.ok) {
// A pending account deletion freezes the account: nothing new is charged.
const frozen = head.frozen ? head.response : null;
if (frozen) return frozen;
return head.response;
}
// A pending account deletion freezes the account: nothing new is charged.
const frozen = await refuseWhenAccountDeletionPending(user.id);
if (frozen) return frozen;
const parsed = chatRequestSchema.safeParse(
await request.json().catch(() => null),
const { user, parsed, chatSession, sessionModel } = head;
prepPhases.push(
{ phase: "prep.auth", durationMs: head.timings.authMs, status: "completed" },
{ phase: "prep.deletion", durationMs: head.timings.deletionMs, status: "completed" },
{ phase: "prep.session", durationMs: head.timings.sessionMs, status: "completed" },
{ phase: "prep.model", durationMs: head.timings.modelMs, status: "completed" },
{ phase: prepParallel ? "prep.flag.parallel-on" : "prep.flag.parallel-off", durationMs: 0, status: "completed" },
{ phase: modelVersionCacheTtlMs() > 0 ? "prep.flag.cache-on" : "prep.flag.cache-off", durationMs: 0, status: "completed" },
{ phase: consultDomainConcurrency() === 2 ? "prep.flag.domains-2" : "prep.flag.domains-1", durationMs: 0, status: "completed" },
);
if (!parsed.success) {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() },
{ status: 400 },
);
}
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession) {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
if (chatSession.session_type !== "consultation") {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
const sessionModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!sessionModel) {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
const sessionContextWindow = sessionModel.contextWindow;
if (parsed.data.entrypoint === "birth_time_rectification") {
@@ -421,14 +394,58 @@ export async function POST(request: Request) {
// Content moderation, input side (2026-09-30): before any reservation, so a
// blocked question is never sent to a model and never charged.
const moderationContext = { route: "consult", userId: user.id, requestId: parsed.data.requestId, modelId: chatSession.model_id };
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
let inputModerationPending: Promise<ModerationResult> | null = null;
let inputModerationRecorded = false;
let moderationTimed = false;
let prepLogged = false;
const moderationStartedAt = Date.now();
function logPrep() {
if (prepLogged) return;
prepLogged = true;
logAgentObservability(consultPrepObservabilityEvent({
requestId,
sessionId,
phases: prepPhases,
}));
}
async function settleInputModeration(): Promise<ModerationResult | null> {
if (!inputModerationPending) return null;
const result = await inputModerationPending;
if (!moderationTimed) {
moderationTimed = true;
prepPhases.push({
phase: "prep.moderation",
durationMs: Math.max(0, Date.now() - moderationStartedAt),
status: result.verdict === "block" ? "failed" : "completed",
});
}
if (result.verdict !== "pass" && !inputModerationRecorded) {
inputModerationRecorded = true;
await recordModerationEvent(accounting, result, "input", moderationContext);
}
return result;
}
function contentBlockedResponse(result: ModerationResult) {
return NextResponse.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(inputModeration) },
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
{ status: 400 },
);
}
if (!prepParallel) {
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
prepPhases.push({
phase: "prep.moderation",
durationMs: Math.max(0, Date.now() - moderationStartedAt),
status: inputModeration.verdict === "block" ? "failed" : "completed",
});
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
logPrep();
return contentBlockedResponse(inputModeration);
}
} else {
inputModerationPending = moderate(visibleQuestion, "input", moderationContext);
}
type SelectedModel = NonNullable<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
@@ -443,6 +460,7 @@ export async function POST(request: Request) {
// pre-stream order because its text/plain replies cannot ride an NDJSON stream.
const runTurn = async (): Promise<Response> => {
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
const prepareStarted = Date.now();
try {
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
userId,
@@ -475,13 +493,25 @@ export async function POST(request: Request) {
return data;
},
loadCandidateRange: (profileUserId) => loadReportCandidateRange(accounting, { userId: profileUserId }),
beforeReserve: ({ consultationMode }) => createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
}),
reserve: () => reserveConsultationModel(
beforeReserve: async ({ consultationMode }) => {
prepPhases.push({
phase: "prep.subject",
durationMs: Math.max(0, Date.now() - prepareStarted),
status: "completed",
});
const inputModeration = await settleInputModeration();
if (inputModeration?.verdict === "block") throw new ConsultationInputBlockedError(inputModeration);
return createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
});
},
reserve: async () => {
const reserveStarted = Date.now();
try {
return await reserveConsultationModel(
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
async (model) => {
@@ -503,9 +533,22 @@ export async function POST(request: Request) {
error_code: reservation.data.reason,
};
},
),
);
} finally {
prepPhases.push({
phase: "prep.reserve",
durationMs: Math.max(0, Date.now() - reserveStarted),
status: "completed",
});
}
},
});
} catch (error) {
const blocked = await settleInputModeration();
if (blocked?.verdict === "block") {
logPrep();
return contentBlockedResponse(blocked);
}
if (error instanceof FeaturePricingError) {
console.error(
`[billing] reservation failed request=${requestId} reason=${error.code}`,
@@ -626,6 +669,7 @@ export async function POST(request: Request) {
}
let appendedQuestion: { success: boolean; error_code?: string | null };
const appendStarted = Date.now();
try {
appendedQuestion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("append_consultation_question", {
@@ -640,7 +684,18 @@ export async function POST(request: Request) {
}
return parsedAppend.data;
});
prepPhases.push({
phase: "prep.append",
durationMs: Math.max(0, Date.now() - appendStarted),
status: "completed",
});
} catch {
prepPhases.push({
phase: "prep.append",
durationMs: Math.max(0, Date.now() - appendStarted),
status: "failed",
});
logPrep();
await cancel();
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
@@ -670,6 +725,14 @@ export async function POST(request: Request) {
let classificationOutcome: string | undefined;
let classificationDurationMs: number | null = null;
let latencyState: ReturnType<typeof createConsultationRuntimeState> | null = null;
const titleStarted = Date.now();
const titleRead = supabase
.from("chat_sessions")
.select("title")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
const classifyStarted = Date.now();
const turn = parsed.data.entrypoint === undefined
? await classifyConsultationTurn({
model: selectedModel,
@@ -705,8 +768,14 @@ export async function POST(request: Request) {
},
})
: { kind: "consult" as const };
prepPhases.push({
phase: "prep.classify",
durationMs: classificationDurationMs ?? Math.max(0, Date.now() - classifyStarted),
status: "completed",
});
if (turn.kind === "smalltalk") {
logPrep();
return streamSmalltalkResponse({
requestId,
reply: turn.reply,
@@ -738,12 +807,13 @@ export async function POST(request: Request) {
}
const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : "";
const { data: titleRow } = await supabase
.from("chat_sessions")
.select("title")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
const { data: titleRow } = await titleRead;
prepPhases.push({
phase: "prep.title",
durationMs: Math.max(0, Date.now() - titleStarted),
status: "completed",
});
logPrep();
async function checkpointConsultationContext() {
try {
+7
View File
@@ -1839,6 +1839,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
outline-offset: 3px;
}
.composer { width: min(760px, 100%); display: flex; flex-direction: column; gap: var(--space-2); margin: 0 auto; border: 1px solid var(--color-border-strong); transition: border-color 140ms ease-out; padding: var(--space-3) var(--space-3) var(--space-2); border-radius: var(--radius-lg); background: var(--color-canvas); }
.composer-ai-notice { width: min(760px, 100%); margin: var(--space-2) auto 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-overline); line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
/* The composer's own bottom row. It replaces .composer-footer, a strip below
the composer that held a permanent 44px whether or not it had anything to
show. The row only exists when there is a toolbar or a count to render. */
@@ -4341,6 +4342,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.personal-report-ai-notice {
margin: 0 0 var(--space-3);
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
line-height: 1.45;
}
.personal-report-focus-card {
display: grid;
gap: var(--space-3);
@@ -12,6 +12,7 @@ import {
serverComposerDraftSnapshot,
subscribeComposerDraft,
} from "@/lib/composer-draft";
import { CHAT_AI_NOTICE_ZH } from "@/lib/ai-generated-notice";
import { COMPOSER_QUEUE_LABEL, COMPOSER_QUEUE_RECALL_LABEL } from "@/lib/queued-draft";
export const composerRemainingId = "composer-character-remaining";
@@ -133,6 +134,7 @@ export function ChatComposer({
</div>
</div>
</form>
<p className="composer-ai-notice">{CHAT_AI_NOTICE_ZH}</p>
</>
);
}
@@ -12,6 +12,7 @@ import {
parseReportChartBlock,
toNorthIndianChart,
} from "@/lib/report-chart-block";
import { reportAiNotice } from "@/lib/ai-generated-notice";
import { parseReportFocusBlock, reportTitleByline } from "@/lib/report-focus-block";
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
@@ -69,10 +70,12 @@ function markdownComponents(
h1: ({ children }) => {
const title = flattenText(children).trim();
const line = byline.shown ? null : reportTitleByline({ title, subjectName, createdAt, language });
const notice = byline.shown ? null : reportAiNotice(language);
byline.shown = true;
return (
<>
<h1>{children}</h1>
{notice ? <p className="personal-report-ai-notice">{notice}</p> : null}
{line ? <p className="personal-report-md-byline">{line}</p> : null}
</>
);
+14
View File
@@ -0,0 +1,14 @@
import type { ReportLanguage } from "./report-language.ts";
/** Visible label under the chat field. The product shell is Chinese. */
export const CHAT_AI_NOTICE_ZH = "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。";
/** English equivalent, locked for a future English shell. Not shown in the Chinese UI. */
export const CHAT_AI_NOTICE_EN = "AI-generated. For reference only; not medical, legal or financial advice.";
export const REPORT_AI_NOTICE_ZH = "本报告由 AI 生成,仅供参考。";
export const REPORT_AI_NOTICE_EN = "This report is AI-generated and for reference only.";
export function reportAiNotice(language: ReportLanguage): string {
return language === "en" ? REPORT_AI_NOTICE_EN : REPORT_AI_NOTICE_ZH;
}
+62
View File
@@ -0,0 +1,62 @@
/** Call-time switches for consult preparation. Read on each request, not at import. */
const warned = new Set<string>();
function warnOnce(name: string, raw: string): void {
if (warned.has(name)) return;
warned.add(name);
console.warn(`[consult-prep] ${name} 的值无法识别,按默认处理`);
void raw;
}
function readRaw(env: Readonly<Record<string, string | undefined>>, name: string): string | undefined {
const raw = env[name];
if (raw === undefined) return undefined;
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
export function consultPrepParallelEnabled(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const raw = readRaw(env, "CONSULT_PREP_PARALLEL");
if (raw === undefined) return false;
const value = raw.toLowerCase();
if (value === "on") return true;
if (value === "off") return false;
warnOnce("CONSULT_PREP_PARALLEL", raw);
return false;
}
/** 0 closes the cache. Anything outside 0–15000 stays on the default, which is off. */
export function modelVersionCacheTtlMs(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = readRaw(env, "MODEL_VERSION_CACHE_TTL_MS");
if (raw === undefined) return 0;
if (!/^\d+$/.test(raw)) {
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
return 0;
}
const parsed = Number(raw);
if (parsed > 15_000) {
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
return 0;
}
return parsed;
}
/** 1 is today's serial loop. 2 starts the capped domains together. */
export function consultDomainConcurrency(
env: Readonly<Record<string, string | undefined>> = process.env,
): 1 | 2 {
const raw = readRaw(env, "CONSULT_DOMAIN_CONCURRENCY");
if (raw === undefined || raw === "1") return 1;
if (raw === "2") return 2;
warnOnce("CONSULT_DOMAIN_CONCURRENCY", raw);
return 1;
}
export function resetConsultPrepFlagWarningsForTests(): void {
warned.clear();
}
+34
View File
@@ -0,0 +1,34 @@
import type { ModerationResult } from "@/lib/moderation";
export const CONSULT_PREP_AGENT_VERSION = "consultation-prep-v1";
export class ConsultationInputBlockedError extends Error {
readonly result: ModerationResult;
constructor(result: ModerationResult) {
super("content_blocked");
this.name = "ConsultationInputBlockedError";
this.result = result;
}
}
export type ConsultPrepPhase = {
phase: string;
durationMs: number;
status: "completed" | "failed" | "skipped";
};
/** Closed observability event. The question text is not a field. */
export function consultPrepObservabilityEvent(input: {
requestId: string;
sessionId: string;
phases: readonly ConsultPrepPhase[];
}) {
return {
requestId: input.requestId,
sessionId: input.sessionId,
agentVersion: CONSULT_PREP_AGENT_VERSION,
contractPhases: input.phases.map((phase) => ({ ...phase })),
billingSettlementResult: "not_applicable" as const,
};
}
+195
View File
@@ -0,0 +1,195 @@
import { NextResponse } from "next/server";
export type ConsultPrepTimings = {
authMs: number;
deletionMs: number;
sessionMs: number;
modelMs: number;
};
type AuthResult<User> = {
data: { user: User | null };
error: unknown;
};
type BodyResult<Parsed> =
| { success: true; data: Parsed }
| { success: false; error: { flatten: () => unknown } };
type SessionResult<Session> = {
data: Session | null;
error: unknown;
};
type SessionRow = {
model_id: string | null;
session_type: string | null;
model_config_version?: number | null;
messages?: unknown;
title?: unknown;
theme?: unknown;
chart_profile_id?: unknown;
chart_profile_name?: unknown;
chart_profile_role?: unknown;
context_summary?: unknown;
};
type ParsedRequest = {
sessionId: string;
modelId: string;
};
export type ConsultTurnHeadDeps<User extends { id: string }, Parsed extends ParsedRequest, Session extends SessionRow, Model> = {
parallel: boolean;
getUser: () => Promise<AuthResult<User>>;
readPendingDeletion: (userId: string) => Promise<Response | null>;
readBody: () => Promise<BodyResult<Parsed>>;
readSession: (userId: string, parsed: Parsed) => Promise<SessionResult<Session>>;
resolveModel: (chatSession: Session) => Promise<Model | null>;
};
export type ConsultTurnHead<User, Parsed, Session, Model> =
| { ok: false; response: Response; frozen: boolean }
| {
ok: true;
user: User;
parsed: { success: true; data: Parsed };
chatSession: Session & { model_id: string };
sessionModel: Model;
timings: ConsultPrepTimings;
};
function elapsedSince(started: number): number {
return Math.max(0, Date.now() - started);
}
function loginRequired(): Response {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
}
function badBody(error: { flatten: () => unknown }): Response {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: error.flatten() },
{ status: 400 },
);
}
function sessionUnreadable(): Response {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
function sessionMissing(): Response {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
function notConsultation(): Response {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
function modelChanged(): Response {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
function modelUnavailable(): Response {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
type Chain<Parsed, Session, Model> =
| { ok: false; response: Response }
| {
ok: true;
parsed: { success: true; data: Parsed };
chatSession: Session & { model_id: string };
sessionModel: Model;
};
/**
* Auth, then either the old serial checks or deletion in parallel with
* body → session → model. Failures are still reported in the old order.
*/
export async function loadConsultTurnHead<
User extends { id: string },
Parsed extends ParsedRequest,
Session extends SessionRow,
Model,
>(
deps: ConsultTurnHeadDeps<User, Parsed, Session, Model>,
): Promise<ConsultTurnHead<User, Parsed, Session, Model>> {
const timings: ConsultPrepTimings = { authMs: 0, deletionMs: 0, sessionMs: 0, modelMs: 0 };
const authStarted = Date.now();
const auth = await deps.getUser();
timings.authMs = elapsedSince(authStarted);
const user = auth.data.user;
if (auth.error || !user) return { ok: false, response: loginRequired(), frozen: false };
const readChain = async (): Promise<Chain<Parsed, Session, Model>> => {
const body = await deps.readBody();
if (!body.success) return { ok: false, response: badBody(body.error) };
const sessionStarted = Date.now();
const session = await deps.readSession(user.id, body.data);
timings.sessionMs = elapsedSince(sessionStarted);
if (session.error) return { ok: false, response: sessionUnreadable() };
if (!session.data) return { ok: false, response: sessionMissing() };
const chatSession = session.data;
if (chatSession.session_type !== "consultation") return { ok: false, response: notConsultation() };
if (!chatSession.model_id || chatSession.model_id !== body.data.modelId) {
return { ok: false, response: modelChanged() };
}
const modelStarted = Date.now();
const sessionModel = await deps.resolveModel(chatSession);
timings.modelMs = elapsedSince(modelStarted);
if (!sessionModel) return { ok: false, response: modelUnavailable() };
return {
ok: true,
parsed: body,
chatSession: chatSession as Session & { model_id: string },
sessionModel,
};
};
if (!deps.parallel) {
const deletionStarted = Date.now();
const frozen = await deps.readPendingDeletion(user.id);
timings.deletionMs = elapsedSince(deletionStarted);
if (frozen) return { ok: false, response: frozen, frozen: true };
const chain = await readChain();
if (!chain.ok) return { ok: false, response: chain.response, frozen: false };
return { ...chain, user, timings };
}
const deletionStarted = Date.now();
const deletion = deps.readPendingDeletion(user.id).then((frozen) => {
timings.deletionMs = elapsedSince(deletionStarted);
return frozen;
});
const chain = readChain();
const [frozen, settled] = await Promise.all([deletion, chain]);
if (frozen) return { ok: false, response: frozen, frozen: true };
if (!settled.ok) return { ok: false, response: settled.response, frozen: false };
return { ...settled, user, timings };
}
+47 -6
View File
@@ -3,8 +3,22 @@ import "server-only";
import type { MastraModelConfig } from "@mastra/core/llm";
import { queryAdminRows } from "@/lib/admin/database";
import { modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
import { assertAllowedModelProviderUrl } from "@/lib/epay/gateway-policy";
import { decryptModelProviderApiKey, type ModelProviderType } from "@/lib/model-provider-policy";
/** Tests replace the database, decrypt and DNS steps. Production leaves these as the real functions. */
const catalogIo = {
queryAdminRows,
assertAllowedModelProviderUrl,
decryptModelProviderApiKey,
};
export function setModelCatalogIoForTests(next: Partial<typeof catalogIo> | null): void {
catalogIo.queryAdminRows = next?.queryAdminRows ?? queryAdminRows;
catalogIo.assertAllowedModelProviderUrl = next?.assertAllowedModelProviderUrl ?? assertAllowedModelProviderUrl;
catalogIo.decryptModelProviderApiKey = next?.decryptModelProviderApiKey ?? decryptModelProviderApiKey;
}
import type { LanguageModelCatalog, ResolvedLanguageModel } from "@/mastra/model";
const cacheTtlMs = 15_000;
@@ -17,7 +31,11 @@ type PublishedModelRow = {
context_window: number | null;
};
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache };
type VersionHit = { expiresAt: number; model: ResolvedLanguageModel };
const state = globalThis as typeof globalThis & {
jyotishaModelCatalogCache?: Cache;
jyotishaModelVersionCache?: Map<string, VersionHit>;
};
function isSecretSettingKey(key: string) {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -37,7 +55,7 @@ function publicModel(model: ResolvedLanguageModel) {
return { id: model.id, label: model.label, description: model.description, creditCost: model.creditCost, isDefault: model.isDefault };
}
async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel | null> {
const apiKey = decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
const apiKey = catalogIo.decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
if (!apiKey) return null;
let model: MastraModelConfig;
if (row.provider_type === "openai") {
@@ -45,7 +63,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
} else if (row.provider_type === "anthropic") {
model = { providerId: "anthropic", modelId: row.provider_model.replace(/^anthropic\//, ""), apiKey };
} else {
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await catalogIo.assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
}
return {
id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost,
@@ -55,7 +73,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
};
}
async function queryCatalog(where: string, values: readonly unknown[] = []) {
return queryAdminRows<PublishedModelRow>(`
return catalogIo.queryAdminRows<PublishedModelRow>(`
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key,
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost,
@@ -86,9 +104,32 @@ async function readVersion(modelId: string, version: number) {
const rows = await queryCatalog("c.model_id=$1 and v.version=$2 and v.status in ('published','retired') and v.enabled and p.enabled", [modelId, version]);
return rows[0] ? resolveRow(rows[0]) : null;
}
function versionCache(): Map<string, VersionHit> {
return state.jyotishaModelVersionCache ??= new Map();
}
export async function resolveSessionLanguageModel(modelId: string, configVersion: number | null | undefined) {
if (configVersion !== null && configVersion !== undefined) { try { return await readVersion(modelId, configVersion); } catch { return null; } }
if (configVersion !== null && configVersion !== undefined) {
const ttl = modelVersionCacheTtlMs();
const key = `${modelId}:${configVersion}`;
const cache = versionCache();
if (ttl > 0) {
const hit = cache.get(key);
if (hit && hit.expiresAt > Date.now()) return hit.model;
}
try {
const model = await readVersion(modelId, configVersion);
// Failures and null stay uncached so a disabled provider is not pinned.
if (model && ttl > 0) cache.set(key, { expiresAt: Date.now() + ttl, model });
return model;
} catch {
return null;
}
}
const catalog = await loadLanguageModelCatalog();
return catalog.models.find((model) => model.id === modelId) ?? null;
}
export function invalidateLanguageModelCatalog() { delete state.jyotishaModelCatalogCache; }
export function invalidateLanguageModelCatalog() {
delete state.jyotishaModelCatalogCache;
state.jyotishaModelVersionCache?.clear();
}
+108 -50
View File
@@ -27,6 +27,7 @@ import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit
import type { AgentModelFinishReason, EvidenceCardTelemetry } from "../lib/agent-observability.ts";
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
import {
buildEvidenceCard,
citedEvidenceCardFields,
@@ -1227,57 +1228,114 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
try {
const userIntent = ctx.plan?.userIntent ?? input.question;
const executions: DomainExecution[] = [];
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
if (consultDomainConcurrency() === 1) {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
});
}
} else {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
}
const parallelExecutions = await Promise.all(domains.map(async (domain) => {
if (!domain) return null;
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
return {
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
};
}));
for (const execution of parallelExecutions) {
if (execution) executions.push(execution);
}
}
// Domains the cap refused and domains the clock ran out on are the
// same disclosure to the caller: requested but not calculated.
+3
View File
@@ -98,6 +98,9 @@ function readTestOtp(env: IdentityEnvironment): string | null {
if (!/^\d{6}$/.test(value)) {
throw new Error("IDENTITY_TEST_OTP must be a 6-digit code when set");
}
if (readOrigin(env, "AUTH_USER_ORIGIN") === "https://jyotisha.chat") {
throw new Error("IDENTITY_TEST_OTP is forbidden when AUTH_USER_ORIGIN is the production site");
}
return value;
}
@@ -0,0 +1,37 @@
-- BUG-1063: the report worker reads another person's archive through
-- service_role (loadSubjectBirth → CHART_SUBJECT_SELECT). The 2026-07-18
-- repair revoked every privilege on chart_profiles and never granted SELECT
-- back. Column-level SELECT only, matching CHART_SUBJECT_SELECT. No write,
-- no RLS change, no change to authenticated grants.
begin;
grant select (
id,
user_id,
role,
name,
birth_date,
reported_birth_time,
birth_time_source,
birth_time_period,
declared_window_start,
declared_window_end,
uncertainty_before_minutes,
uncertainty_after_minutes,
latitude,
longitude,
timezone_id,
timezone_offset,
birth_place_label,
birth_place_type,
birth_place_provider,
birth_place_provider_id,
ayanamsa,
active_birth_time,
active_birth_date,
active_birth_timezone_offset,
birth_time_status,
gender
) on table public.chart_profiles to service_role;
commit;
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { createElement, createRef } from "react";
import { readFileSync } from "node:fs";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatComposer } from "../src/components/chat-composer.tsx";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
import {
CHAT_AI_NOTICE_EN,
CHAT_AI_NOTICE_ZH,
REPORT_AI_NOTICE_EN,
REPORT_AI_NOTICE_ZH,
} from "../src/lib/ai-generated-notice.ts";
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const rectification = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
function composerHtml(): string {
return renderToStaticMarkup(createElement(ChatComposer, {
inputRef: createRef<HTMLTextAreaElement>(),
value: "",
inputLabel: "输入你的问题",
placeholder: "想聊什么都可以",
maxLength: 500,
inputDisabled: false,
submitLabel: "发送",
submitBlocked: false,
stopVisible: false,
stopLabel: "停止回答",
stopTitle: "停止回答",
onSubmit() {},
onChange() {},
onKeyDown() {},
onStop() {},
}));
}
test("both chat surfaces share the composer notice, under the field and not over the send control", () => {
assert.equal(CHAT_AI_NOTICE_ZH, "回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。");
assert.equal(CHAT_AI_NOTICE_EN, "AI-generated. For reference only; not medical, legal or financial advice.");
const html = composerHtml();
const noticeAt = html.indexOf("composer-ai-notice");
const sendAt = html.indexOf("发送");
assert.ok(noticeAt > sendAt, "the notice follows the send control");
assert.match(html, new RegExp(CHAT_AI_NOTICE_ZH));
assert.match(page, /<ChatComposer/);
assert.match(rectification, /<ChatComposer/);
assert.doesNotMatch(page, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
assert.doesNotMatch(rectification, /CHAT_AI_NOTICE_ZH|composer-ai-notice/);
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*white-space:\s*nowrap/);
assert.match(styles, /\.composer-ai-notice\s*\{[^}]*overflow:\s*hidden/);
assert.doesNotMatch(styles, /\.composer-footer\s*\{/);
});
test("report readers show the AI notice in Chinese and English under the title", () => {
assert.equal(REPORT_AI_NOTICE_ZH, "本报告由 AI 生成,仅供参考。");
assert.equal(REPORT_AI_NOTICE_EN, "This report is AI-generated and for reference only.");
const chinese = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
markdown: "# 虚构报告\n\n一段说明。",
language: "zh",
}));
const english = renderToStaticMarkup(createElement(PersonalReportMarkdownView, {
markdown: "# Fictional report\n\nA paragraph.",
language: "en",
}));
assert.ok(chinese.indexOf("<h1>") < chinese.indexOf(REPORT_AI_NOTICE_ZH));
assert.match(english, new RegExp(REPORT_AI_NOTICE_EN.replaceAll(".", "\\.")));
});
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import test from "node:test";
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
// BUG-1063: every column loadSubjectBirth selects from chart_profiles must
// have a service_role SELECT grant after the 2026-07-18 revoke.
const REVOKE = "20260718100000";
const directory = new URL("../supabase/migrations/", import.meta.url);
const grantFile = new URL(
"../supabase/migrations/20261014010000_chart_profiles_service_role_select.sql",
import.meta.url,
);
function serviceRoleChartSelectGrants(): Set<string> {
const granted = new Set<string>();
for (const name of readdirSync(directory).filter((file) => file.endsWith(".sql")).sort()) {
if (name.slice(0, 14) < REVOKE) continue;
const sql = readFileSync(new URL(name, directory), "utf8").replace(/--.*$/gm, "");
const pattern = /grant\s+([a-z ,]+?)\s*\(([^)]*)\)\s*on\s+table\s+public\.chart_profiles\s+to\s+([a-z_, ]+);/gi;
for (const match of sql.matchAll(pattern)) {
if (!/select/i.test(match[1]!) || !/service_role/.test(match[3]!)) continue;
for (const column of match[2]!.split(",")) {
const trimmed = column.trim();
if (trimmed) granted.add(trimmed);
}
}
}
return granted;
}
test("service_role chart_profiles select covers CHART_SUBJECT_SELECT and nothing writable", () => {
const sql = readFileSync(grantFile, "utf8").replace(/--.*$/gm, "");
assert.doesNotMatch(sql, /grant\s+(insert|update|delete)\b/i);
assert.doesNotMatch(sql, /create\s+policy|alter\s+policy|enable\s+row\s+level\s+security/i);
assert.doesNotMatch(sql, /to\s+authenticated/i);
const missing = CHART_SUBJECT_SELECT.split(",").map((column) => column.trim()).filter((column) => !serviceRoleChartSelectGrants().has(column));
assert.deepEqual(missing, []);
});
+12 -7
View File
@@ -7,6 +7,7 @@ const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", impor
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const turnPrep = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
const sql = readFileSync(
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
"utf8",
@@ -59,14 +60,18 @@ test("session list GET omits messages while detail GET returns them", () => {
});
test("consult POST on a rectification session is 409, a missing id is 404", () => {
// 原值:!chatSession || session_type !== consultation 一律 404「咨询会话不存在」
// 新值:查不到 404;类型不对 409 session_not_consultation
// 原因:BUG-925 前端无法区分不存在与类型不对
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*status: 404/);
assert.match(consultRoute, /code: "session_not_consultation"/);
assert.match(consultRoute, /status: 409/);
// 原值:consult route 里 /if (!chatSession) {[\s\S]*status: 404/,以及同文件的 session_not_consultation 与 409
// 新值:turn-prep.ts 里 sessionMissing() 为 404,notConsultation() 为 409 session_not_consultation,两处调用分开
// 原因:判定从 route.ts 挪进 turn-prep.ts;仍是查不到 404、类型不对 409,不得并成一个判断
assert.match(turnPrep, /function sessionMissing\(\)[\s\S]*status: 404/);
assert.match(turnPrep, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(turnPrep, /function notConsultation\(\)[\s\S]*code: "session_not_consultation"[\s\S]*status: 409/);
assert.match(
turnPrep,
/if \(chatSession\.session_type !== "consultation"\) return \{ ok: false, response: notConsultation\(\) \}/,
);
assert.doesNotMatch(
consultRoute,
turnPrep,
/if \(!chatSession \|\| chatSession\.session_type !== "consultation"\)/,
);
});
@@ -255,18 +255,23 @@ test("a turn that throws before producing any response ends the stream with a ge
test("the consult route opens the stream before subject reads, reservation, question append and classification", () => {
const route = source("../src/app/api/consult/route.ts");
const head = source("../src/lib/consult/turn-prep.ts");
const openIndex = route.indexOf("const runTurn = async (): Promise<Response> =>");
assert.ok(openIndex > 0);
// Stays in front of the stream: auth, body, session existence and type, the session model.
// 原值: 401、请求体、会话不存在、会话类型、模型已变这些字面量都在 route.ts 的 runTurn 之前。
// 新值: 这些字面量在 turn-prep.ts;route.ts 在 runTurn 之前调用 loadConsultTurnHead 并返回失败响应。提示词拦截仍在 route.ts。
// 原因: 开流前的独立读取收进同一个函数,两种开关的状态码仍在开流前返回。
assert.ok(route.indexOf("loadConsultTurnHead") < openIndex);
assert.ok(route.indexOf("if (!head.ok)") < openIndex);
assert.ok(route.indexOf("if (blocksPromptExtraction(userControlledPrompt))") < openIndex);
for (const marker of [
"status: 401",
'{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() }',
'{ error: "出生资料或问题格式不正确", details: error.flatten() }',
'error: "咨询会话不存在"',
'code: "session_not_consultation"',
'error: "会话模型已经变化"',
"if (blocksPromptExtraction(userControlledPrompt))",
]) {
assert.ok(route.indexOf(marker) < openIndex, `${marker} must answer with an HTTP status`);
assert.ok(head.includes(marker), `${marker} stays an HTTP response before the stream`);
}
// Inside the stream: the slow part, and every rejection it can raise.
for (const marker of [
@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { register } from "node:module";
import test from "node:test";
import { agentObservabilityEventSchema } from "../src/lib/agent-observability.ts";
import { ConsultationInputBlockedError, consultPrepObservabilityEvent } from "../src/lib/consult/prep-log.ts";
import { inputBlockedCopy, MODERATION_SELF_HARM_COPY } from "../src/lib/moderation/index.ts";
import type { ModerationResult } from "../src/lib/moderation/types.ts";
import { streamFirstResponse } from "../src/lib/stream-first-response.ts";
const blocked: ModerationResult = {
verdict: "block",
categories: ["self_harm"],
ruleIds: ["lexicon"],
providerId: "local",
};
test("a blocked question records once and never reaches reserve", async () => {
let reserves = 0;
let records = 0;
const pending = Promise.resolve(blocked);
async function beforeReserve() {
const result = await pending;
if (result.verdict !== "pass") records += 1;
if (result.verdict === "block") throw new ConsultationInputBlockedError(result);
reserves += 1;
}
await assert.rejects(beforeReserve, (error: unknown) => {
assert.ok(error instanceof ConsultationInputBlockedError);
assert.equal(inputBlockedCopy(error.result), MODERATION_SELF_HARM_COPY);
return true;
});
assert.equal(reserves, 0);
assert.equal(records, 1);
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const blockedAt = route.indexOf('blocked?.verdict === "block"');
const profileAt = route.indexOf("error instanceof ConsultationProfileTruthError");
assert.ok(blockedAt > 0 && blockedAt < profileAt);
});
test("moderation that takes 1.5s does not hold the first frame", async () => {
let release: (result: ModerationResult) => void = () => {};
const pending = new Promise<ModerationResult>((resolve) => {
release = resolve;
});
const started = Date.now();
const response = streamFirstResponse({
requestId: "prep-frame",
run: async () => {
const result = await pending;
return Response.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
{ status: 400 },
);
},
});
const reader = response.body!.getReader();
const first = await reader.read();
assert.ok(Date.now() - started < 400, "first frame waited for moderation");
assert.ok(first.value && first.value.byteLength > 0);
setTimeout(() => release(blocked), 1_500);
const chunks: string[] = [];
const decoder = new TextDecoder();
while (true) {
const next = await reader.read();
if (next.value) chunks.push(decoder.decode(next.value));
if (next.done) break;
}
const events = chunks.join("").trim().split("\n").map((line) => JSON.parse(line));
const rejected = events.find((event) => event.type === "run.failed");
assert.equal(rejected.message, MODERATION_SELF_HARM_COPY);
assert.equal(rejected.reason, "content_blocked");
assert.equal(rejected.status, 400);
});
test("prep observability accepts the closed schema and drops the question", () => {
const question = "这句问题原文不该出现在日志里";
const event = consultPrepObservabilityEvent({
requestId: "req-prep",
sessionId: "sess-prep",
phases: [
{ phase: "prep.model", durationMs: 3, status: "completed" },
{ phase: "prep.moderation", durationMs: 12, status: "completed" },
],
});
assert.deepEqual(agentObservabilityEventSchema.parse(event), event);
assert.equal(JSON.stringify(event).includes(question), false);
assert.equal(event.agentVersion, "consultation-prep-v1");
});
test("readVersion cache skips a second database and DNS lookup inside the ttl", async () => {
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
let queries = 0;
let dns = 0;
const row = {
model_id: "model-a",
version: 4,
label: "Model A",
description: "fixture",
provider_model: "demo",
credit_cost: 1,
is_default: false,
provider_code: "demo",
provider_type: "openai-compatible",
base_url: "https://example.com/v1",
encrypted_api_key: "enc",
input_cost: 1,
output_cost: 1,
context_window: 8000,
};
const catalog = await import("../src/lib/model-catalog.ts");
const flags = await import("../src/lib/consult/prep-flags.ts");
catalog.setModelCatalogIoForTests({
queryAdminRows: (async () => {
queries += 1;
if (queries === 3) throw new Error("database down");
if (queries >= 4) return [];
return [row];
}) as typeof import("../src/lib/admin/database.ts").queryAdminRows,
assertAllowedModelProviderUrl: (async (value: string) => {
dns += 1;
return { url: new URL(value), addresses: [] };
}) as typeof import("../src/lib/epay/gateway-policy.ts").assertAllowedModelProviderUrl,
decryptModelProviderApiKey: (() => "test-key") as typeof import("../src/lib/model-provider-policy.ts").decryptModelProviderApiKey,
});
process.env.MODEL_VERSION_CACHE_TTL_MS = "15000";
try {
catalog.invalidateLanguageModelCatalog();
const first = await catalog.resolveSessionLanguageModel("model-a", 4);
const second = await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(first?.id, "model-a");
assert.equal(second, first);
assert.equal(queries, 1);
assert.equal(dns, 1);
catalog.invalidateLanguageModelCatalog();
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(queries, 2);
process.env.MODEL_VERSION_CACHE_TTL_MS = "0";
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.ok(queries >= 3);
const failed = await catalog.resolveSessionLanguageModel("model-a", 4);
const empty = await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(failed, null);
assert.equal(empty, null);
const queriesAfterMiss = queries;
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.ok(queries > queriesAfterMiss);
} finally {
flags.resetConsultPrepFlagWarningsForTests();
delete process.env.MODEL_VERSION_CACHE_TTL_MS;
catalog.invalidateLanguageModelCatalog();
catalog.setModelCatalogIoForTests(null);
}
});
test("the title read starts after the question is saved and is awaited at the old use site", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const append = route.indexOf('accounting.rpc("append_consultation_question"');
const title = route.indexOf('.select("title")');
const classify = route.indexOf("await classifyConsultationTurn({");
const awaited = route.indexOf("await titleRead");
assert.ok(append > 0 && title > append && classify > title && awaited > classify);
});
@@ -0,0 +1,79 @@
/**
* In-process overlap check for consult preparation.
*
* This does not measure local Postgres. Those medians were not collected,
* so the switches stay at their code defaults.
*/
import { register } from "node:module";
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
const { loadConsultTurnHead } = await import("../src/lib/consult/turn-prep.ts");
const delays = { deletion: 50, session: 80, model: 80 };
async function run(parallel: boolean) {
let now = 0;
const waiters: Array<{ at: number; resolve: () => void }> = [];
let finished = false;
let inFlight = 0;
let maxInFlight = 0;
function sleep(ms: number) {
return new Promise<void>((resolve) => {
waiters.push({ at: now + ms, resolve });
});
}
async function occupy(ms: number) {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await sleep(ms);
inFlight -= 1;
}
const pending = loadConsultTurnHead({
parallel,
getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }),
readPendingDeletion: () => occupy(delays.deletion).then(() => null),
readBody: async () => ({ success: true as const, data: { sessionId: "s", modelId: "m" } }),
readSession: () => occupy(delays.session).then(() => ({
data: { model_id: "m", model_config_version: 2, session_type: "consultation" },
error: null,
})),
resolveModel: () => occupy(delays.model).then(() => ({ id: "m" })),
}).then((head) => {
finished = true;
return head;
});
for (let guard = 0; guard < 40 && !finished; guard += 1) {
await Promise.resolve();
await Promise.resolve();
if (!waiters.length) continue;
waiters.sort((left, right) => left.at - right.at);
now = waiters[0]!.at;
const due: Array<() => void> = [];
while (waiters.length && waiters[0]!.at <= now) due.push(waiters.shift()!.resolve);
for (const resolve of due) resolve();
}
if (!finished) throw new Error("clock did not settle");
const head = await pending;
if (!head.ok) throw new Error("head failed");
return { elapsed: now, maxInFlight };
}
const on = await run(true);
const off = await run(false);
const report = {
kind: "logical-clock",
delays,
parallelElapsed: on.elapsed,
serialElapsed: off.elapsed,
parallelMaxInFlight: on.maxInFlight,
serialMaxInFlight: off.maxInFlight,
postgresMedians: "not-measured",
aliyunKey: "absent",
moderationSimulationMs: [300, 1500],
decision: {
CONSULT_PREP_PARALLEL: "off",
MODEL_VERSION_CACHE_TTL_MS: 0,
reason: "开启条件要求本机 Postgres 中位数。本轮没有量到,默认保持关闭。",
},
};
console.log(JSON.stringify(report));
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadConsultTurnHead } from "../src/lib/consult/turn-prep.ts";
type User = { id: string };
type Parsed = { sessionId: string; modelId: string };
type Session = { model_id: string | null; model_config_version: number | null; session_type: string | null };
type Model = { id: string };
function okSession(): Session {
return { model_id: "m", model_config_version: 2, session_type: "consultation" };
}
function deps(overrides: Record<string, unknown> = {}) {
const calls = { deletion: 0, session: 0, model: 0 };
const base = {
parallel: false,
getUser: async () => ({ data: { user: { id: "user-1" } as User }, error: null }),
readPendingDeletion: async () => {
calls.deletion += 1;
return null;
},
readBody: async () => ({ success: true as const, data: { sessionId: "session-1", modelId: "m" } as Parsed }),
readSession: async () => {
calls.session += 1;
return { data: okSession(), error: null };
},
resolveModel: async () => {
calls.model += 1;
return { id: "m" } as Model;
},
...overrides,
};
return { calls, deps: base };
}
async function snapshot(response: Response) {
return { status: response.status, body: await response.json() };
}
async function both(overrides: Record<string, unknown> = {}) {
const off = deps({ ...overrides, parallel: false });
const on = deps({ ...overrides, parallel: true });
const [left, right] = await Promise.all([
loadConsultTurnHead(off.deps),
loadConsultTurnHead(on.deps),
]);
assert.equal(left.ok, false);
assert.equal(right.ok, false);
if (left.ok || right.ok) return;
assert.deepEqual(await snapshot(left.response), await snapshot(right.response));
}
test("serial and parallel heads return the same status for each gate", async () => {
const anonymous = deps({
getUser: async () => ({ data: { user: null }, error: null }),
});
const missingUser = await loadConsultTurnHead({ ...anonymous.deps, parallel: false });
assert.equal(missingUser.ok, false);
if (!missingUser.ok) assert.equal(missingUser.response.status, 401);
assert.equal(anonymous.calls.deletion, 0);
await both({
readPendingDeletion: async () => Response.json({ error: "账号正在注销" }, { status: 423 }),
readBody: async () => ({ success: false as const, error: { flatten: () => ({ formErrors: ["bad"] }) } }),
});
await both({
readBody: async () => ({ success: false as const, error: { flatten: () => ({ formErrors: ["bad"] }) } }),
});
await both({ readSession: async () => ({ data: null, error: { message: "down" } }) });
await both({ readSession: async () => ({ data: null, error: null }) });
await both({
readSession: async () => ({ data: { ...okSession(), session_type: "rectification" }, error: null }),
});
await both({
readSession: async () => ({ data: { ...okSession(), model_id: "other" }, error: null }),
});
await both({ resolveModel: async () => null });
});
test("a frozen account wins over a bad request body", async () => {
const frozen = deps({
parallel: true,
readPendingDeletion: async () => Response.json({ error: "账号正在注销" }, { status: 423 }),
readBody: async () => ({ success: false as const, error: { flatten: () => ({}) } }),
});
const head = await loadConsultTurnHead(frozen.deps);
assert.equal(head.ok, false);
if (!head.ok) {
assert.equal(head.frozen, true);
assert.equal(head.response.status, 423);
}
});
test("parallel head overlaps deletion with session and model", async () => {
const delays = { deletion: 50, session: 80, model: 80 };
async function run(parallel: boolean) {
let now = 0;
const waiters: Array<{ at: number; resolve: () => void }> = [];
function sleep(ms: number) {
return new Promise<void>((resolve) => {
waiters.push({ at: now + ms, resolve });
});
}
let deletionStarted = 0;
let chainStarted = 0;
let inFlight = 0;
let maxInFlight = 0;
let finished = false;
async function occupy(ms: number, mark: () => void) {
mark();
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await sleep(ms);
inFlight -= 1;
}
const pending = loadConsultTurnHead({
parallel,
getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }),
readPendingDeletion: () => occupy(delays.deletion, () => { deletionStarted = now; }).then(() => null),
readBody: async () => ({ success: true as const, data: { sessionId: "s", modelId: "m" } }),
readSession: () => occupy(delays.session, () => { chainStarted = now; }).then(() => ({ data: okSession(), error: null })),
resolveModel: () => occupy(delays.model, () => {}).then(() => ({ id: "m" })),
}).then((head) => {
finished = true;
return head;
});
for (let guard = 0; guard < 40 && !finished; guard += 1) {
await Promise.resolve();
await Promise.resolve();
if (!waiters.length) continue;
waiters.sort((left, right) => left.at - right.at);
now = waiters[0]!.at;
const due: Array<() => void> = [];
while (waiters.length && waiters[0]!.at <= now) due.push(waiters.shift()!.resolve);
for (const resolve of due) resolve();
}
if (!finished) throw new Error("clock did not settle");
const head = await pending;
assert.equal(head.ok, true);
return { elapsed: now, deletionStarted, chainStarted, maxInFlight };
}
const on = await run(true);
const off = await run(false);
assert.ok(on.elapsed < 230, `parallel elapsed ${on.elapsed}`);
assert.equal(on.maxInFlight, 2);
assert.equal(off.maxInFlight, 1);
assert.equal(on.deletionStarted, on.chainStarted);
assert.ok(off.chainStarted >= delays.deletion);
assert.ok(off.elapsed > on.elapsed);
});
@@ -138,7 +138,7 @@ type PlanResult = Record<string, unknown> & {
async function runDomainPlan(
domains: string[],
runWorkflow: (theme: string) => ReturnType<typeof workflow>,
runWorkflow: (theme: string) => ReturnType<typeof workflow> | Promise<ReturnType<typeof workflow>>,
options: { now?: () => number; requestId?: string } = {},
) {
const state = createConsultationRuntimeState();
@@ -623,6 +623,63 @@ test("the single-value domain form stays available to callers without the model
assert.throws(() => canonicalDomainPlan({ theme: "prashna" }, {}), /unsupported_consultation_domain/);
});
test("two domains stay in plan order when concurrency is 2 and the second finishes first", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "2";
const finished: string[] = [];
try {
const { result } = await runDomainPlan(["career", "marriage"], async (theme) => {
await new Promise((resolve) => setTimeout(resolve, theme === "career" ? 40 : 5));
finished.push(theme);
return workflow(theme);
});
assert.deepEqual(finished, ["marriage", "career"]);
assert.deepEqual(result.domains, ["career", "marriage"]);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("a 429 from one parallel domain keeps workflow_rate_limited", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "2";
try {
await assert.rejects(
() => runDomainPlan(["career", "marriage"], async () => {
throw new ConsultationWorkflowError("workflow_rate_limited", "busy");
}),
(error: unknown) => {
assert.equal(consultationToolFailureCode(error), "workflow_rate_limited");
return true;
},
);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("concurrency 1 still runs domains one at a time", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "1";
let active = 0;
let maxActive = 0;
try {
await runDomainPlan(["career", "marriage"], async (theme) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 20));
active -= 1;
return workflow(theme);
});
assert.equal(maxActive, 1);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("daily entrypoint ignores a model domain rewrite instead of executing it", async () => {
const calls: string[] = [];
const state = createConsultationRuntimeState();
@@ -157,8 +157,20 @@ test("consultation plans are server-owned and bounded", () => {
const modelToolInput = tools.slice(tools.indexOf("const consultationToolInputSchema"), tools.indexOf("const MAX_RECORDED_STEPS"));
assert.doesNotMatch(modelToolInput, /birth|latitude|longitude|engine|script|credit/i);
const prepareCall = route.slice(route.indexOf("prepared = await prepareConsultationRoute"), route.indexOf("const modelSelection"));
assert.match(prepareCall, /beforeReserve: \(\{ consultationMode \}\) => createConsultationPlan\(\{/);
assert.ok(prepareCall.indexOf("beforeReserve:") < prepareCall.indexOf("reserve: () => reserveConsultationModel("));
// 原值:beforeReserve: ({ consultationMode }) => createConsultationPlan({,且早于 reserve: () => reserveConsultationModel(
// 新值:beforeReserve 的 async 函数体里调用 createConsultationPlan({,并且整段早于 reserve;reserve 的 async 函数体里调用 reserveConsultationModel(
// 原因:两处改成 async 函数体。仍锁调用发生在对应回调里,并且 beforeReserve 在 reserve 之前
const beforeReserveAt = prepareCall.indexOf("beforeReserve:");
const reserveAt = prepareCall.indexOf("reserve: async");
assert.ok(beforeReserveAt >= 0 && reserveAt > beforeReserveAt);
assert.match(
prepareCall.slice(beforeReserveAt, reserveAt),
/beforeReserve:\s*async\s*\(\{\s*consultationMode\s*\}\)[\s\S]*createConsultationPlan\(\{/,
);
assert.match(
prepareCall.slice(reserveAt),
/reserve:\s*async\s*\(\)\s*=>[\s\S]*reserveConsultationModel\(/,
);
assert.ok(routeService.indexOf("const preReserveResult = input.beforeReserve") < routeService.indexOf("const reservation = await input.reserve()"));
assert.match(route, /plan: prepared\.preReserveResult,[\s\S]*theme: consultationTheme/);
assert.match(route, /runConsultationWorkflow\(toolInput, \{[\s\S]*plan: prepared\.preReserveResult/);
+9 -2
View File
@@ -239,8 +239,15 @@ test("a blocked answer is replaced in place and onComplete settles the notice",
test("routes check input before any charge and output before settlement", () => {
const consult = read("../src/app/api/consult/route.ts");
assert.ok(consult.indexOf('await moderate(visibleQuestion, "input"') > 0);
assert.ok(consult.indexOf('await moderate(visibleQuestion, "input"') < consult.indexOf("reserve_consultation_usage"), "input check precedes the reservation");
const prepare = read("../src/lib/consultation-route-service.ts");
// 原值: 源码含 await moderate(visibleQuestion, "input" 且位置早于 reserve_consultation_usage
// 新值: beforeReserve 里 await settleInputModeration(),而 prepare 里 beforeReserve 早于 input.reserve()
// 原因: 审核从开流前挪到流内、预留前;开关关掉时仍在开流前 await moderate
assert.match(consult, /await settleInputModeration\(\)/);
assert.ok(consult.indexOf("await settleInputModeration()") < consult.indexOf("reserve_consultation_usage"));
const beforeReserve = prepare.indexOf("input.beforeReserve");
const reserveCall = prepare.indexOf("input.reserve()");
assert.ok(beforeReserve > 0 && beforeReserve < reserveCall);
assert.equal((consult.match(/moderateOutput: moderateAnswer/g) ?? []).length, 3, "every agentic answer stream is checked");
assert.match(consult, /if \(!outputModerated\) await moderateAnswer\(reply\.text\);\n\s+if \(outputBlocked\) return await completeModeratedResponse\(usage\);/);
assert.match(consult, /complete_consultation_moderated/);
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { loadReportWorkerProfile } from "../src/lib/personal-report-worker-subject.ts";
import { CHART_SUBJECT_SELECT } from "../src/lib/subject-birth.ts";
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
// BUG-1063: the report worker reads another person's archive through
// service_role. Fictional rows only.
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const SERVICE_URL_ROLE = ["service_runtime", "service-runtime-test-password"] as const;
function applyMigrations(fixture: PostgresFixture): void {
const result = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"),
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
}
test("service_role can read another person's chart profile for a report", {
skip: docker ? false : "docker unavailable",
}, async () => {
const fixture = startPostgresFixture();
const serviceUrl = fixture.connectionUrl(...SERVICE_URL_ROLE);
try {
applyMigrations(fixture);
const owner = randomUUID();
const subjectId = randomUUID();
fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at)
values ('${owner}', 'Fictional owner', '${owner}@example.invalid', true, now());`);
fixture.psql(`insert into public.chart_profiles (
id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time,
birth_time_source, latitude, longitude, timezone_id, timezone_offset,
birth_place_label, ayanamsa, birth_time_status
) values (
'${subjectId}', '${owner}', 'other', '{"name":"虚构乙"}'::jsonb, now(),
'虚构乙', '1990-01-01', '08:00', 'family_exact', 22.3, 114.1,
'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported'
);`);
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
const probe = await admin
.from("chart_profiles")
.select(CHART_SUBJECT_SELECT)
.eq("id", subjectId)
.maybeSingle();
assert.equal(probe.error, null, probe.error?.message ?? "chart_profiles select failed");
const loaded = await loadReportWorkerProfile(admin, owner, subjectId) as { name?: string };
assert.equal(loaded.name, "虚构乙");
for (const column of CHART_SUBJECT_SELECT.split(",")) {
assert.equal(
fixture.psql(`select has_column_privilege('service_role', 'public.chart_profiles', '${column}', 'select')`),
"t",
column,
);
}
for (const privilege of ["insert", "update", "delete"]) {
assert.equal(
fixture.psql(`select has_table_privilege('service_role', 'public.chart_profiles', '${privilege}')`),
"f",
privilege,
);
}
assert.equal(
fixture.psql("select has_table_privilege('authenticated', 'public.chart_profiles', 'select')"),
"t",
);
} finally {
await closeLocalPostgresDataPool(serviceUrl);
fixture.stop();
}
});
+18 -7
View File
@@ -12,6 +12,9 @@ import { join } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";
// Windows CreateProcess finds System32 bash before PATH. CI leaves this unset.
const bash = process.env.JYOTISHA_BASH || "bash";
import {
databaseHealthFromFilenames,
RECTIFICATION_CONTRACT_VERSION,
@@ -218,7 +221,7 @@ test("staging model provider env preparation removes legacy settings and keeps o
try {
writeFileSync(envFile, "APP_ENV_FILE=../.env.staging\nOPENAI_API_KEY=legacy\nLLM_MODEL=legacy\n");
chmodSync(envFile, 0o600);
const first = spawnSync("bash", [prepare, envFile], { encoding: "utf8" });
const first = spawnSync(bash, [prepare, envFile], { encoding: "utf8" });
assert.equal(first.status, 0, first.stderr);
const prepared = readFileSync(envFile, "utf8");
assert.match(prepared, /^APP_ENV_FILE=\.\.\/\.env\.staging$/m);
@@ -227,7 +230,7 @@ test("staging model provider env preparation removes legacy settings and keeps o
assert.ok(key);
assert.equal(statSync(envFile).mode & 0o777, 0o600);
const second = spawnSync("bash", [prepare, envFile], { encoding: "utf8" });
const second = spawnSync(bash, [prepare, envFile], { encoding: "utf8" });
assert.equal(second.status, 0, second.stderr);
const secondKey = readFileSync(envFile, "utf8").match(/^MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=([A-Za-z0-9+/]{43}=)$/m)?.[1];
assert.equal(secondKey, key);
@@ -267,7 +270,7 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
"PERSONAL_REPORT_DAILY_LIMIT=5",
];
const run = () =>
spawnSync("bash", [validator, envFile], { encoding: "utf8" });
spawnSync(bash, [validator, envFile], { encoding: "utf8" });
const writeEnv = (lines: string[], mode = 0o600) => {
writeFileSync(envFile, `${lines.join("\n")}\n`);
chmodSync(envFile, mode);
@@ -515,20 +518,28 @@ test("production env validators accept only self-hosted production selectors and
try {
writeEnv(appEnv, appLines);
writeEnv(databaseEnv, databaseLines);
assert.equal(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
for (const forbidden of ["IDENTITY_TEST_OTP=123456", "IDENTITY_TEST_OTP="]) {
writeEnv(appEnv, [...appLines, forbidden]);
const rejected = spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" });
assert.notEqual(rejected.status, 0, forbidden);
assert.match(rejected.stderr, /IDENTITY_TEST_OTP is forbidden in production/);
}
writeEnv(appEnv, appLines);
assert.equal(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.equal(spawnSync(bash, [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
writeEnv(appEnv, appLines.map((line) =>
line.startsWith("ADMIN_USER_ORIGIN=")
? "ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat"
: line,
));
assert.notEqual(spawnSync("bash", [appValidator, appEnv], { encoding: "utf8" }).status, 0);
assert.notEqual(spawnSync(bash, [appValidator, appEnv], { encoding: "utf8" }).status, 0);
writeEnv(databaseEnv, databaseLines.filter((line) =>
!line.startsWith("PRODUCTION_BACKUP_ENCRYPTION_KEY="),
));
assert.notEqual(spawnSync("bash", [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
assert.notEqual(spawnSync(bash, [databaseValidator, databaseEnv], { encoding: "utf8" }).status, 0);
} finally {
rmSync(root, { recursive: true, force: true });
}
+32
View File
@@ -77,3 +77,35 @@ test("identity test OTP channel is opt-in and strictly six digits", () => {
/must be a 6-digit/,
);
});
test("production origin refuses a configured test OTP and staging still accepts one", () => {
assert.throws(
() => readIdentityConfig({
...selfHostedEnvironment,
AUTH_USER_ORIGIN: "https://jyotisha.chat",
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
IDENTITY_TEST_OTP: "123456",
}),
/forbidden when AUTH_USER_ORIGIN is the production site/,
);
const unset = readIdentityConfig({
...selfHostedEnvironment,
AUTH_USER_ORIGIN: "https://jyotisha.chat",
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
});
assert.equal(unset.provider === "self-hosted" ? unset.testOtp : "unexpected", null);
const staging = readIdentityConfig({ ...selfHostedEnvironment, IDENTITY_TEST_OTP: "123456" });
assert.equal(staging.provider === "self-hosted" ? staging.testOtp : null, "123456");
});
test("production origin with a trailing slash still refuses a test OTP", () => {
assert.throws(
() => readIdentityConfig({
...selfHostedEnvironment,
AUTH_USER_ORIGIN: "https://jyotisha.chat/",
ADMIN_USER_ORIGIN: "https://admin.jyotisha.chat",
IDENTITY_TEST_OTP: "123456",
}),
/forbidden when AUTH_USER_ORIGIN is the production site/,
);
});
+11 -2
View File
@@ -45,9 +45,18 @@ test("catalog cache is short-lived and failure stays fail-closed without env fal
test("session model resolution is async and pins published or retired database versions", () => {
assert.match(catalog, /export async function resolveSessionLanguageModel/);
assert.match(catalog, /c\.model_id=\$1 and v\.version=\$2 and v\.status in \('published','retired'\)/);
assert.match(catalog, /return await readVersion\(modelId, configVersion\)/);
// 原值: return await readVersion(modelId, configVersion)
// 新值: const model = await readVersion(modelId, configVersion),只有非空结果写入短缓存
// 原因: 绑版本的解析与目录共用 15 秒上限,失败和空结果不缓存
assert.match(catalog, /const model = await readVersion\(modelId, configVersion\)/);
assert.match(catalog, /if \(model && ttl > 0\) cache\.set\(key, \{ expiresAt: Date\.now\(\) \+ ttl, model \}\)/);
assert.match(routes.consult, /await resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version/);
assert.match(routes.consult, /if \(!sessionModel\)[\s\S]*status: 503/);
// 原值: route.ts 里 if (!sessionModel) 之后是 status 503
// 新值: turn-prep.ts 里 if (!sessionModel) 返回 modelUnavailable(),正文 status 503
// 原因: 模型不可用仍是开流前的 503,文案随准备步骤一起搬走
const head = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
assert.match(head, /if \(!sessionModel\) return \{ ok: false, response: modelUnavailable\(\) \}/);
assert.match(head, /function modelUnavailable\(\)[\s\S]*status: 503/);
assert.doesNotMatch(routes.consult, /languageModelConfigurationMessage/);
});
@@ -18,7 +18,6 @@ import {
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const run = read("../src/hooks/use-consultation-run.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const consultRoute = read("../src/app/api/consult/route.ts");
const pageSource = read("../src/app/(app)/page.tsx");
const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"'));
@@ -50,13 +49,16 @@ test("pending consult replay pointing at a rectification session is dropped", ()
});
test("consult POST splits missing session from wrong session type", () => {
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(
consultRoute,
/if \(chatSession\.session_type !== "consultation"\) \{[\s\S]*session_not_consultation[\s\S]*status: 409/,
);
assert.match(consultRoute, /这是生时校正会话/);
assert.match(consultRoute, /请在生时校正里继续。/);
const head = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
// 原值: consult route 里 if (!chatSession) 返回 404,类型不对返回 session_not_consultation。
// 新值: 同样两句在 turn-prep.ts,route 把读会话的结果交给它。
// 原因: 开流前的会话判断收进准备函数,文案和状态码没变。
assert.match(head, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(head, /function sessionMissing\(\)[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(head, /function notConsultation\(\)[\s\S]*session_not_consultation[\s\S]*status: 409/);
assert.match(head, /session_type !== "consultation"/);
assert.match(head, /这是生时校正会话/);
assert.match(head, /请在生时校正里继续。/);
assert.equal(SESSION_NOT_CONSULTATION_CODE, "session_not_consultation");
assert.match(run, /caught\.code === SESSION_NOT_CONSULTATION_CODE/);
});
@@ -6,6 +6,9 @@ import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
// Windows CreateProcess finds System32 bash before PATH. CI leaves this unset.
const bash = process.env.JYOTISHA_BASH || "bash";
const giteaQualityWorkflow = new URL(
"../../.gitea/workflows/backend-quality-gate.yml",
import.meta.url,
@@ -999,7 +1002,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
chmodSync(join(mockBin, "flock"), 0o755);
try {
const result = spawnSync("bash", [fileURLToPath(deployScript)], {
const result = spawnSync(bash, [fileURLToPath(deployScript)], {
encoding: "utf8",
env: {
...process.env,
@@ -1117,6 +1120,9 @@ test("manual release gate runs where Docker Compose v2 is available", () => {
assert.match(workflow, /NODE_TOOL_IMAGE: node:22-bookworm-slim/);
assert.match(workflow, /node --version \| grep -Eq '\^v22\\\.'/);
assert.match(workflow, /docker compose version --short \| grep -Eq '\^v\?2\\\.'/);
assert.match(workflow, /RESOLVE_MODE: discover/);
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /H relative to R is docs-only/);
assert.match(workflow, /PIP_INDEX_URL: https:\/\/mirrors\.aliyun\.com\/pypi\/simple\//);
assert.match(workflow, /NPM_CONFIG_REGISTRY: https:\/\/registry\.npmmirror\.com/);
for (const dependency of [
@@ -1139,7 +1145,27 @@ test("production deploy is manual-only and consumes the accepted staging artifac
assert.match(production, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(production, /endswith\("release-quality-gate\.yml"\)/);
assert.match(production, /public staging has not accepted the requested SHA/);
assert.match(production, /current_main[\s\S]*DEPLOY_SHA[\s\S]*current_staging[\s\S]*DEPLOY_SHA/);
// 原值: current_main 与 current_staging 都必须等于 DEPLOY_SHA
// 新值: 两者必须相等,并且用门禁产物里的 is-docs-only-range.sh 确认相对 DEPLOY_SHA 仍是纯文档
// 原因: 产品 2026-10-14 允许 staging head 在最近一次门禁提交之后只前进文档
assert.match(production, /\[\[ "\$current_main" == "\$current_staging" \]\]/);
assert.match(production, /bash "\$checker" --api "\$DEPLOY_SHA" "\$current_staging"/);
assert.match(production, /bash deploy\/resolve-release-revision\.sh/);
// 原值: /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/ 同时匹配回滚那一行
// 新值: 正向断言锚在 >>"$GITHUB_OUTPUT" 的那一次调用
// 原因: 删掉正向那一处时,回滚行仍能让旧正则通过
assert.match(production, /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh >>"\$GITHUB_OUTPUT"/);
// 原值: 回滚部署不读恢复点格式
// 新值: allow_rollback 为 true 时,主机上的恢复点格式必须 >= 2
// 原因: 8 月以来不带权限的旧恢复点不能再用来回退
assert.match(production, /ALLOW_ROLLBACK" == true[\s\S]*recovery format is below 2; dispatch Create production recovery first/);
// 原值: 回滚用 main 头自己的 push 门禁,jq 取不到 first 就失败
// 新值: 回滚的控制器改用 RESOLVE_MODE=discover 解析出的 R;目标仍须在已审查的 main 历史里
// 原因: 测试环境头只多了文档时没有门禁记录,说明书里的回退会被拒绝
assert.match(production, /RESOLVE_MODE=discover CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/);
assert.match(production, /rollback revision is not in reviewed main history/);
assert.doesNotMatch(production, /head_sha=\$controller_sha&branch=staging/);
assert.doesNotMatch(production, /stale production revision refused/);
assert.match(production, /verification_mode:/);
assert.match(production, /PRODUCTION_HOST/);
assert.match(production, /118\.194\.235\.34/);
@@ -1162,10 +1188,12 @@ test("deployment scripts pass shell syntax validation", () => {
productionSyncScript,
productionEnvValidator,
productionDatabaseEnvValidator,
new URL("../../deploy/resolve-release-revision.sh", import.meta.url),
new URL("../../deploy/rehearse-production-migrations.sh", import.meta.url),
]) {
const path = fileURLToPath(script);
chmodSync(path, 0o755);
const result = spawnSync("bash", ["-n", path], { encoding: "utf8" });
const result = spawnSync(bash, ["-n", path], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
}
});
@@ -1205,6 +1233,10 @@ test("production runner validates state and migrations before switching exact im
assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/);
assert.match(runner, /another production mutation holds the host lock/);
// 原值: 回滚部署不读恢复点格式
// 新值: ALLOW_ROLLBACK=true 时主机脚本也拒绝低于 2 的恢复点
// 原因: 工作流检查被绕过时,回退仍不能使用 8 月的旧恢复点
assert.match(runner, /\[ "\$ALLOW_ROLLBACK" = "true" \][\s\S]*recovery format is below 2; dispatch Create production recovery first/);
assert.match(runner, /sync-production-tree\.sh/);
assertOrder(runner, [
"validate-production-env.sh",
@@ -1246,8 +1278,17 @@ test("production recovery workflow creates a verified encrypted off-site artifac
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"\)/);
// 原值: main_head 与 staging_head 都必须等于 DEPLOY_SHA,且工作流正文里用 jq 找 release 门
// 新值: 调用 resolve-release-revision.sh,dispatch 的 GITEA_SHA 必须等于脚本给出的 head_sha
// 原因: 恢复点跟四个生产工作流共用同一条「最近门禁提交 + 其后纯文档」规则
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
// 原值: 恢复点工作流没有断言锁住 CHECK_RELEASE_GATE=true
// 新值: 恢复点必须带着这个开关调用解析脚本
// 原因: 与部署、迁移同一条发布门禁,删掉开关不能仍是绿的
assert.match(workflow, /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /\[\[ "\$GITEA_SHA" == "\$head_sha" \]\]/);
assert.match(workflow, /\[\[ "\$release_sha" == "\$DEPLOY_SHA" \]\]/);
assert.doesNotMatch(workflow, /main_head == "\$DEPLOY_SHA" && "\$staging_head" == "\$DEPLOY_SHA"/);
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 \}\}/);
@@ -1274,10 +1315,18 @@ test("production recovery workflow creates a verified encrypted off-site artifac
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/);
// 原值: pg_dump 与 pg_restore 都带 --no-owner --no-acl
// 新值: 只保留 --format=custom,还原核对也不再丢掉属主和权限
// 原因: 旧恢复点还原后网站账号对所有表都没有权限,四个计数却仍然对得上
assert.match(runner, /pg_dump[\s\S]*--format=custom/);
assert.doesNotMatch(runner, /--no-owner|--no-acl/);
assert.match(runner, /table_grants_match/);
assert.match(runner, /column_privileges_match/);
assert.match(runner, /table_owners_match/);
assert.match(runner, /RECOVERY_FORMAT=2/);
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, /pg_restore[\s\S]*--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\}/);
@@ -1291,15 +1340,25 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/);
assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /GITEA_SHA.*DEPLOY_SHA/);
// 原值: main_head、staging_head、GITEA_SHA 都必须等于 DEPLOY_SHA,门禁 jq 写在工作流里
// 新值: 工作流调用 resolve-release-revision.sh;GITEA_SHA 对齐 head_sha,DEPLOY_SHA 对齐 release_sha
// 原因: 生产迁移与部署接受同一发布修订,不再要求被 dispatch 的 main 提交自己有 push 门禁
assert.match(workflow, /bash deploy\/resolve-release-revision\.sh/);
// 原值: 生产迁移工作流没有断言锁住 CHECK_RELEASE_GATE=true
// 新值: 生产迁移必须带着这个开关调用解析脚本
// 原因: 与部署、恢复点同一条发布门禁,删掉开关不能仍是绿的
assert.match(workflow, /CHECK_RELEASE_GATE=true bash deploy\/resolve-release-revision\.sh/);
assert.match(workflow, /\[\[ "\$GITEA_SHA" == "\$head_sha" \]\]/);
assert.match(workflow, /\[\[ "\$release_sha" == "\$DEPLOY_SHA" \]\]/);
assert.doesNotMatch(workflow, /GITEA_SHA" == "\$DEPLOY_SHA"/);
assert.match(workflow, /STAGING_URL: \$\{\{ vars\.STAGING_URL \}\}/);
assert.match(workflow, /\[\[ "\$STAGING_URL" == "https:\/\/staging\.jyotisha\.chat" \]\]/);
assert.match(workflow, /"\$STAGING_URL\/api\/health"/);
assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /branch=staging&event=push&status=success/);
assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
assert.match(read(new URL("../../deploy/resolve-release-revision.sh", import.meta.url)), /backend-quality-gate\.yml/);
assert.match(read(new URL("../../deploy/resolve-release-revision.sh", import.meta.url)), /release-quality-gate\.yml/);
assert.match(workflow, /\[\[ "\$current_main" == "\$current_staging" \]\]/);
assert.match(workflow, /bash "\$checker" --api "\$DEPLOY_SHA" "\$current_staging"/);
assert.match(workflow, /group: production-mutation/);
assert.match(workflow, /SSH_PRIVATE_KEY_BASE64: \$\{\{ secrets\.PRODUCTION_SSH_PRIVATE_KEY \}\}/);
assert.match(workflow, /PRODUCTION_KNOWN_HOSTS/);
@@ -1311,6 +1370,11 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
assert.match(workflow, /recovery_created_at:[\s\S]*required: true[\s\S]*type: string/);
assert.match(workflow, /restore_verified:[\s\S]*required: true[\s\S]*default: false[\s\S]*type: boolean/);
assert.match(workflow, /\[\[ "\$RESTORE_VERIFIED" == "true" \]\]/);
// 原值: 生产迁移不读恢复点格式
// 新值: 读取 RECOVERY_FORMAT,低于 2 就拒绝并提示先创建恢复点
// 原因: 不带权限的旧恢复点不能再当作这次迁移的退路
assert.match(workflow, /RECOVERY_FORMAT/);
assert.match(workflow, /recovery format is below 2; dispatch Create production recovery first/);
assert.match(workflow, /%Y-%m-%dT%H:%M:%SZ/);
assert.match(workflow, /age > timedelta\(hours=24\)/);
assert.match(workflow, /migration files run sequentially and are not atomic as a whole/);
@@ -1334,6 +1398,8 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
assert.match(runner, /date -u -d "\$RECOVERY_CREATED_AT" \+%s/);
assert.match(runner, /recovery_age_seconds <= 24 \* 60 \* 60/);
assert.match(runner, /production recovery point must have restore_verified=true/);
assert.match(runner, /RECOVERY_FORMAT/);
assert.match(runner, /recovery format is below 2; dispatch Create production recovery first/);
assert.match(runner, /migration files run sequentially and are not atomic as a whole/);
assert.match(runner, /migration_runner_database_url="postgresql:\/\/migration_runner:/);
assert.match(runner, /role%3Dschema_owner/);
@@ -1582,7 +1648,7 @@ test("is-docs-only-range.sh decides from local history and refuses non-ancestor
git("commit", "-q", "-m", message);
return git("rev-parse", "HEAD");
};
const run = (...args: string[]) => spawnSync("bash", [script, ...args], { cwd: root, env, encoding: "utf8" });
const run = (...args: string[]) => spawnSync(bash, [script, ...args], { cwd: root, env, encoding: "utf8" });
try {
git("init", "-q", "-b", "staging");
@@ -1700,7 +1766,7 @@ esac
] as const) {
const cleanupLog = join(root, "cleanup.log");
writeFileSync(cleanupLog, "");
const result = spawnSync("bash", ["-c", script], {
const result = spawnSync(bash, ["-c", script], {
cwd: root,
env: { ...process.env, PATH: `${root}:${process.env.PATH}`, TMPDIR: root,
NODE_TOOL_SOURCE_IMAGE: "test-image", NPM_CONFIG_REGISTRY: "https://registry.npmmirror.com",
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,8 @@
const empty = new URL("./server-only-empty.mjs", import.meta.url).href;
export async function resolve(specifier, context, nextResolve) {
if (specifier === "server-only") {
return { url: empty, shortCircuit: true };
}
return nextResolve(specifier, context);
}
+9 -2
View File
@@ -1632,9 +1632,12 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
'reports/[reportId]',
'terms',
]
# 原值: 期望集合含 'Case Validation'
# 新值: 去掉 'Case Validation'
# 原因: 唯一命中它的是从未挂载的内部面板 evidence-audit-panel.tsx(含 MEVG 字样),
# BUG-1342 删掉该死组件后,用户界面上本来就看不到案例校验;审计现在如实不再列出
assert set(audit['surfaces']['app_visible_topics']) == {
'Birth Rectification',
'Case Validation',
'Synastry 16-factor',
}
assert '/api/deep_varga_avastha' in audit['surfaces']['api_endpoints']
@@ -1666,7 +1669,11 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
assert all('ux_next_action' in row for row in ux['next_queue'])
ux_by_id = {row['id']: row for row in ux['rows']}
assert ux_by_id['birth_time_rectifier']['ux_level'] in {'excellent', 'usable'}
assert ux_by_id['case_validator']['ux_level'] == 'excellent'
# 原值: case_validator 的 ux_level == 'excellent'
# 新值: == 'thin'
# 原因: 同上,'excellent' 靠的是从未挂载的 evidence-audit-panel.tsx 被扫成「界面可见」;
# BUG-1342 删掉它后按真实界面评分,案例校验没有用户入口,评为 thin 是如实结果
assert ux_by_id['case_validator']['ux_level'] == 'thin'
assert ux_by_id['synastry_16factor']['ux_level'] == 'excellent'
for technique_id in [
'ashtakavarga_pav',
@@ -0,0 +1,240 @@
"""Safety checks for the disposable migration rehearsal. No production database."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "deploy" / "rehearse-production-migrations.sh"
def resolve_bash() -> str:
found = shutil.which("bash")
git_bash = Path(r"D:\Program Files\Git\bin\bash.exe")
if os.name == "nt" and git_bash.exists():
lowered = (found or "").lower()
if not found or "system32" in lowered or "windowsapps" in lowered:
return str(git_bash)
if found:
return found
if git_bash.exists():
return str(git_bash)
return "bash"
BASH = resolve_bash()
def run(env: dict[str, str]) -> subprocess.CompletedProcess[str]:
full = os.environ.copy()
full.update(env)
return subprocess.run(
[BASH, str(SCRIPT)],
cwd=ROOT,
env=full,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
def test_refuses_a_non_production_path() -> None:
completed = run({"DEPLOY_PATH": "/tmp/not-production"})
assert completed.returncode == 1
assert "unexpected production path" in completed.stderr
def test_refuses_the_production_database_name() -> None:
completed = run(
{
"JYOTISHA_REHEARSAL_LOCAL": "1",
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/jyotisha",
}
)
assert completed.returncode == 1
assert "refusing to touch the production database" in completed.stderr
assert "secret-value" not in completed.stdout
assert "secret-value" not in completed.stderr
def test_plan_keeps_the_stop_file_out_of_the_first_batch() -> None:
completed = run(
{
"JYOTISHA_REHEARSAL_LOCAL": "1",
"JYOTISHA_REHEARSAL_PLAN": "1",
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/rehearse_plan",
}
)
assert completed.returncode == 0, completed.stderr
text = completed.stdout
assert "rehearsal_plan=1" in text
assert "database_name=rehearse_plan" in text
assert "stop_before=20260925030000_chart_subject_trust_and_limit.sql" in text
assert "stop_in_before=no" in text
values = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
assert int(values["batch_before"]) < int(values["batch_all"])
assert "secret-value" not in completed.stdout
assert "secret-value" not in completed.stderr
def test_local_url_with_query_is_refused() -> None:
completed = run(
{
"JYOTISHA_REHEARSAL_LOCAL": "1",
"JYOTISHA_REHEARSAL_DATABASE_URL": "postgresql://postgres:secret-value@127.0.0.1:1/rehearse_plan?database=jyotisha",
}
)
assert completed.returncode == 1
assert "query parameters" in completed.stderr
assert "secret-value" not in completed.stdout
assert "secret-value" not in completed.stderr
def bash_path(path: Path) -> str:
if os.name != "nt":
return str(path)
completed = subprocess.run(
[BASH, "-lc", 'cygpath -u "$1"', "cygpath", str(path)],
check=True,
capture_output=True,
text=True,
)
return completed.stdout.strip()
def test_host_mode_does_not_call_host_node_and_failed_cleanup_is_nonzero(tmp_path: Path) -> None:
bin_dir = tmp_path / "bin"
host = tmp_path / "host"
(host / ".state").mkdir(parents=True)
(host / "backups").mkdir()
dump = host / "backups" / "production-pre-migration-20261014T000000Z.dump.enc"
dump.write_bytes(b"not-a-real-dump")
(host / ".env.production.database").write_text(
"\n".join(
[
"POSTGRES_DB=jyotisha",
"POSTGRES_USER=postgres",
"POSTGRES_PASSWORD=secret-value",
"PRODUCTION_BACKUP_ENCRYPTION_KEY=not-a-real-key",
"",
]
),
encoding="utf-8",
newline="\n",
)
docker_log = tmp_path / "docker.log"
node_log = tmp_path / "node.log"
bin_dir.mkdir()
(bin_dir / "docker").write_text(
"\n".join(
[
"#!/bin/bash",
'printf "%s\\n" "$*" >> "${DOCKER_LOG:?}"',
'case "$*" in',
" *exec\\ -i*|*-i\\ *) cat >/dev/null || true ;;",
"esac",
'joined="$*"',
'case "$joined" in',
' *ps\\ *) echo container1; exit 0 ;;',
"esac",
'case "$joined" in',
" *'{{.Image}}'*) echo sha256:0000000000000000000000000000000000000000000000000000000000000000; exit 0 ;;",
"esac",
'case "$joined" in',
" *inspect*) echo jyotisha-net; exit 0 ;;",
"esac",
'case "$joined" in',
" *DROP\\ DATABASE*) exit 1 ;;",
"esac",
'case "$joined" in',
" *has_table_privilege*)",
" printf '%s\\n' 'authenticated public.profiles true' 'authenticated public.chat_sessions true' 'service_role public.chart_profiles true'",
" exit 0",
" ;;",
"esac",
'case "$joined" in',
" *count*) echo 0; exit 0 ;;",
"esac",
"exit 0",
"",
]
),
encoding="utf-8",
newline="\n",
)
(bin_dir / "node").write_text(
"\n".join(
[
"#!/bin/bash",
'printf "%s\\n" "$*" >> "${NODE_LOG:?}"',
"exit 9",
"",
]
),
encoding="utf-8",
newline="\n",
)
(bin_dir / "openssl").write_text("#!/bin/bash\nexit 0\n", encoding="utf-8", newline="\n")
unix_bin = bash_path(bin_dir)
python_bin = bash_path(Path(sys.executable))
(bin_dir / "python3").write_text(
f"#!/bin/bash\nexec '{python_bin}' \"$@\"\n",
encoding="utf-8",
newline="\n",
)
subprocess.run(
[BASH, "-lc", f'chmod +x "{unix_bin}/docker" "{unix_bin}/node" "{unix_bin}/openssl" "{unix_bin}/python3"'],
check=True,
)
full = os.environ.copy()
full.update(
{
"JYOTISHA_REHEARSAL_FAKE_HOST": "1",
"DEPLOY_PATH": bash_path(host),
"RECOVERY_DUMP": bash_path(dump),
"DOCKER_LOG": bash_path(docker_log),
"NODE_LOG": bash_path(node_log),
}
)
completed = subprocess.run(
[
BASH,
"-lc",
'export PATH="$1:$PATH"; exec bash "$2"',
"bash",
unix_bin,
bash_path(SCRIPT),
],
cwd=ROOT,
env=full,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
assert completed.returncode != 0, completed.stdout + completed.stderr
assert "演练库未删除,请手动 DROP DATABASE rehearse_" in completed.stderr
assert "secret-value" not in completed.stdout
assert "secret-value" not in completed.stderr
assert not node_log.exists()
docker_text = docker_log.read_text(encoding="utf-8")
assert "--entrypoint node" in docker_text
assert "DROP DATABASE" in docker_text
def test_restore_keeps_backup_owners_and_compares_grants() -> None:
text = SCRIPT.read_text(encoding="utf-8")
assert "--no-owner" not in text
assert "ALTER TYPE" not in text
assert "typtype" not in text
assert "revoked_grants" in text
assert "chart_profiles_service_role_select_missing" in text
assert "role%3Dschema_owner" in text
assert "grant connect, create on database" in text
+270
View File
@@ -0,0 +1,270 @@
"""Resolver for the production release SHA. Gitea responses are local fixtures."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "deploy" / "resolve-release-revision.sh"
GATED = ROOT / "deploy" / "gated-paths.txt"
PYTHON = Path(sys.executable)
def resolve_bash() -> str:
found = shutil.which("bash")
git_bash = Path(r"D:\Program Files\Git\bin\bash.exe")
if os.name == "nt" and git_bash.exists():
lowered = (found or "").lower()
if not found or "system32" in lowered or "windowsapps" in lowered:
return str(git_bash)
if found:
return found
if git_bash.exists():
return str(git_bash)
return "bash"
BASH = resolve_bash()
def run_git(repo: Path, *args: str) -> str:
completed = subprocess.run(
["git", "-c", "user.email=dev@example.com", "-c", "user.name=Release Test", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return completed.stdout.strip()
def make_repo(tmp_path: Path) -> dict[str, str]:
repo = tmp_path / "repo"
repo.mkdir()
run_git(repo, "init")
frontend = repo / "frontend"
frontend.mkdir()
(frontend / "app.txt").write_text("gated\n", encoding="utf-8")
run_git(repo, "add", "frontend/app.txt")
run_git(repo, "commit", "-m", "gated")
first = run_git(repo, "rev-parse", "HEAD")
docs = repo / "docs"
docs.mkdir()
(docs / "note.md").write_text("docs only\n", encoding="utf-8")
run_git(repo, "add", "docs/note.md")
run_git(repo, "commit", "-m", "docs")
second = run_git(repo, "rev-parse", "HEAD")
(frontend / "more.txt").write_text("more gated\n", encoding="utf-8")
run_git(repo, "add", "frontend/more.txt")
run_git(repo, "commit", "-m", "more gated")
third = run_git(repo, "rev-parse", "HEAD")
return {"repo": str(repo), "r": first, "h": second, "later": third}
def ref_payload(branch: str, sha: str) -> list[dict]:
return [{"ref": f"refs/heads/{branch}", "object": {"sha": sha}}]
def gate_payload(sha: str, run_id: int = 42) -> dict:
return {
"workflow_runs": [
{
"id": run_id,
"path": ".gitea/workflows/backend-quality-gate.yml",
"head_sha": sha,
"head_branch": "staging",
"event": "push",
"conclusion": "success",
}
]
}
def write_fixtures(directory: Path, manifest: list[dict], files: dict[str, object]) -> None:
directory.mkdir()
for name, payload in files.items():
(directory / name).write_text(json.dumps(payload), encoding="utf-8")
(directory / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
def invoke(repo: str, fixture: Path, env_extra: dict[str, str]) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
# is-docs-only-range.sh calls python3. Windows Git bash has no python3,
# and CreateProcess would rather hit the WSL stub than a PATH python.
shim = fixture.parent / "python3-shim"
shim.mkdir(exist_ok=True)
launcher = shim / "python3"
launcher.write_text(
"#!/bin/bash\n" + f'exec "{PYTHON.as_posix()}" "$@"\n',
encoding="utf-8",
newline="\n",
)
env.update(
{
"JYOTISHA_HTTP_FIXTURE_DIR": str(fixture),
"JYOTISHA_DOCS_ONLY_MODE": "git",
"JYOTISHA_PYTHON": str(PYTHON),
"GATED_PATHS_FILE": str(GATED),
"PATH": str(shim) + os.pathsep + env.get("PATH", ""),
}
)
env.update(env_extra)
return subprocess.run(
[BASH, str(SCRIPT)],
cwd=repo,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
)
def outputs(completed: subprocess.CompletedProcess[str]) -> dict[str, str]:
return dict(line.split("=", 1) for line in completed.stdout.splitlines() if "=" in line)
def test_equal_heads_pass(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "equal"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["r"]),
"staging.json": ref_payload("staging", shas["r"]),
"gate.json": gate_payload(shas["r"]),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 0, completed.stderr
assert outputs(completed)["release_sha"] == shas["r"]
assert outputs(completed)["head_sha"] == shas["r"]
assert outputs(completed)["gate_run_id"] == "42"
def test_docs_only_advance_passes(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "docs"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["h"]),
"staging.json": ref_payload("staging", shas["h"]),
"gate.json": gate_payload(shas["r"], 7),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 0, completed.stderr
assert outputs(completed)["release_sha"] == shas["r"]
assert outputs(completed)["head_sha"] == shas["h"]
assert outputs(completed)["gate_run_id"] == "7"
def test_gated_advance_is_refused(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "gated"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["later"]),
"staging.json": ref_payload("staging", shas["later"]),
"gate.json": gate_payload(shas["h"]),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["h"]})
assert completed.returncode == 1
assert "not a docs-only ancestor" in completed.stderr
def test_non_ancestor_is_refused(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "diverge"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["r"]),
"staging.json": ref_payload("staging", shas["r"]),
"gate.json": gate_payload(shas["later"]),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["later"]})
assert completed.returncode == 1
assert "not a docs-only ancestor" in completed.stderr
def test_missing_gate_run_is_refused(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "nogate"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
{"match": "event=push", "file": "gate.json"},
],
{
"main.json": ref_payload("main", shas["r"]),
"staging.json": ref_payload("staging", shas["r"]),
"gate.json": {"workflow_runs": []},
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 1
assert "no successful exact-SHA production quality gate run found" in completed.stderr
def test_divergent_main_is_refused(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "split"
write_fixtures(
fixture,
[
{"match": "refs/heads/main", "file": "main.json"},
{"match": "refs/heads/staging", "file": "staging.json"},
],
{
"main.json": ref_payload("main", shas["r"]),
"staging.json": ref_payload("staging", shas["h"]),
},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 1
assert "main and staging must identify the same reviewed release" in completed.stderr
def test_api_failure_exits_2(tmp_path: Path) -> None:
shas = make_repo(tmp_path)
fixture = tmp_path / "down"
write_fixtures(
fixture,
[{"match": "refs/heads/staging", "file": "unused.json", "status": 500}],
{"unused.json": {}},
)
completed = invoke(shas["repo"], fixture, {"REQUESTED_SHA": shas["r"]})
assert completed.returncode == 2
assert "Gitea API request failed" in completed.stderr