Merge authoritative main into the gating task branch

Why: task-sync ahead of review; main advanced via #414/#415 (admin-zone
hosting, U1 instance identity).

Evidence: frontend merge verified (vitest/knip/build); dev-level make test
and e2e rerun on this head follow as evidence receipts.

Impact: resolves the modules/frontend gitlink to the component merge head.

Refs: parent evidence in this task card.
This commit is contained in:
2026-10-12 03:28:31 +00:00
8 changed files with 1474 additions and 9 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
# U1 数据归属清单 v0.1(R3"所有数据"的枚举基线)
状态:设计候选,随 U1 评审;配套设计见 [u1-identity-manifest.md](u1-identity-manifest.md)。主记录:方案 r2 第 3 节 R3 与第 6 节(提案在 feature/service-restart-recovery-plan 分支待合入,路径 `modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md`)。标注规则:`已核实`=SELF 源码观察(基线 Dev `89174a0` / Backend 同指针);`待核对`=实施 I2 时必须落定,未落定不得进入备份/恢复范围声明。
本清单只回答"数据在哪里、谁是权威、怎么恢复";耐久性参数(fsync/WAL 同步档)与备份窗口属 U6。
| # | 数据类别 | 权威位置 | 存储/机制 | 恢复方法 | 状态 |
|---|---|---|---|---|---|
| 1 | 团队业务库(账号/项目/成员/消息/文档/卡片/Surface 修订/设置/插件授权/run/step/命令/通知/审批/取消/额度/恢复 hold/事件) | PG 团队库(dbref 指向实例+库) | `modules/backend/internal/store/migrate.go` 表族(v17 pending_notices、run_commands、surface_revisions、recovery_holds、execution 等已核实存在) | PITR/pg_dump 逻辑备份(U6);身份由 U1 DB marker 锚定 | 部分已核实,全表清单待核对 |
| 2 | 个人档库(同 schema) | `<DataDir>/agent-system.db` | SQLite(app.go:196 已核实) | 在线一致性备份接口(现有)+ WAL 同档复制禁止 | 已核实 |
| 3 | 工件对象(内容文件) | `<DataDir>/artifacts`(PG 档;app.go:179 已核实) | blob 临时写+rename(KTD11 对象化目标) | 文件+DB 元数据配对恢复,hash 对账 | 已核实路径,对象语义待 U6 |
| 4 | 上传/输入附件 | `<DataDir>` 下上传子目录 | 同上 | 同上 | 路径待核对 |
| 5 | 文档大内容/版本 | DB 行 +(如启用)内容文件 | documents 域 | DB 恢复+文件对账 | 待核对 |
| 6 | 模型转录(Native Session) | 各工作区 `<sandbox>/.agentx/transcript.jsonl` | llm_direct.go:245 起播种/重建(已核实) | 随工作区恢复;revision 不符可由消息库重播种(已核实的 reseed 机制) | 已核实 |
| 7 | 子代理派生请求 | 工作区内派生请求文件(writeSubagentRequest 已核实存在) | 落盘凭据 | 随工作区恢复 | 机制已核实,路径清单待核对 |
| 8 | 执行工作区/overlay/checkpoint | 受管 workspace 目录 + filestate | KTD7 账本对象 | 工作区对账+副作用记录 | 待核对 |
| 9 | 任务 Git 现场(worktree/未提交文件) | 各任务 worktree | git | 现场+基准 SHA 对账(U6/U7) | 待核对 |
| 10 | 插件自有存储 | skillsRoot 与 sidecar dir(host_v2.go:18/24 已核实机制);各启用插件自有 DB/目录 | 插件自定义 | 插件恢复适配器(r2 §3:必需插件不可恢复则阻塞全系统签收) | 机制已核实,逐插件清单待核对 |
| 11 | RDM 等下游模块存储 | 模块自有目录/库 | 模块自定义 | 同上,纳入兼容清单后覆盖"所有数据"承诺 | 待核对 |
| 12 | 配置与机密 | `runtime/<name>/svc.env`(迁移后 manifest.json+dsn_ref)、`.svc/<name>.json`、后端模型密钥(部署库) | 文件/表 | 受限配置备份(U6);机密独立可恢复 | svc.env 已核实;密钥存储待核对 |
| 13 | 派生状态(可重建,不作权威) | 检索/向量索引(collab U6 向量域)、Redis 缓存(如启用)、`runtime/<name>/history.jsonl` 事件史 | 索引/日志 | 从权威源重建(r2 §5.2 索引行);Redis 可清空 | 部分待核对 |
| 14 | 程序/资产组合 | bin 制品、Web 资产、插件二进制 | 不可变组合 | 组合 manifest+摘要(U1 §3 program 字段、U6 备份) | 已核实(deploy source registry) |
## 清单纪律
- 进入 U6 备份范围与 U7 签收的每一行,实施增量必须把该行"待核对"清零并附证据;无法证明可恢复的必需项按 r2 §3 阻塞全系统签收。
- 新增插件/下游模块上线时必须同步登记本清单(守护测试在 U7 范围评估)。
+84
View File
@@ -0,0 +1,84 @@
# U1 实例与数据身份 manifest 合同(设计 v0.1)
状态:已批准(2026-10-12 用户“按建议办”,四未决项按 §8 建议定案),进入实施 I2。主记录:服务重启与全量数据恢复方案 r2(`modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md` §7 U1 与 KTD1/KTD2;该提案落在本仓 feature/service-restart-recovery-plan 分支待推送合入,合入后即为上述路径);本任务卡 `feature/recovery-u1-instance-identity`。授权:用户批准 r2(2026-10-12)并指示启动 U1;实施(改 `scripts/svc.sh` 与测试)待本设计评审通过。
基线:Dev `89174a0`(含 svc-autorecover 合入:autostart 期望态、`recover`、watchdog、`.start.lock`)。事实来源 SELF·源码观察;本文所有"已核实"均可在该基线复核。
## 1. 目标与不变量
持久配置是启动权威,PID 与临时环境只是观察/覆盖。五条不变量对应 U1 验收:
- **I1 连续性**:连续裸启停(无参数)使用同一 DB 身份与数据/工件/工作区根。
- **I2 非漂移**:只改端口/监听/程序版本/调用 cwd,不改变 DB、数据根与插件组合。
- **I3 原子性**:配置提交是整份原子操作,任意 kill 窗口只可能读到完整旧版或完整新版。
- **I4 阻塞不重建**:必需配置缺失、DB 身份不匹配或数据根 marker 丢失时拒绝启动,绝不新建空库/空目录掩盖。
- **I5 机密引用化**:DSN/凭据不进 manifest 正文与日志,只存受保护文件引用。
## 2. 现状(基线事实)
- 每实例配置位于 `runtime/<name>/svc.env`(扁平 `key=value`,键含 kind/dbref/dsn/auth/exec/addr/autostart/config/source 等),`svc_get`/`svc_set` 逐键读写(已核实)。
- `resolve_db_config()`(svc.sh ≈1373)统一解析 dbref/DSN/config,裸启停复用绑定,绑定失效拒绝启动(已核实)。
- desired state `autostart` 已持久化于 svc.env 并被 recover/watchdog 消费(U2 首切片,PR #396/#401,已核实)。
- 生命周期互斥 `.start.lock` 覆盖 start/stop/restart/deploy/update/recover(已核实)。
- 缺口:逐键更新无原子性(kill 窗口可留中间态);参数重建路径可漂移自定义 data/plugins;无配置 revision 与 CAS;数据根/DB 无身份 marker("看起来是同一目录"≠同一身份);首次 init 与常规 start 无入口区分;`dsn=` 明文存 svc.env。
## 3. manifest v1 结构
文件:`runtime/<name>/manifest.json`(权威)。svc.env 迁移后由 manifest 派生生成、仅供未改造消费方只读(见 §6)。
| 字段组 | 字段 | 说明 |
|---|---|---|
| 元 | `schema`(=1)、`revision`(int,单调)、`updated_at` | 整份 CAS 的版本轴 |
| 身份 | `instance_id`(稳定,创建后不变)、`service_group`、`created_at` | R2 的"实例 ID" |
| 期望态 | `desired`: `running`\|`stopped` | 吸收 autostart;U2 watchdog/recover 改读此字段(映射:autostart=1⇔running) |
| 程序 | `program`: `{source, exec, digest}` | deploy source registry 的组合锚点 |
| 数据库 | `db`: `{mode, dbref, dsn_ref, auth}` | `dsn_ref` 指向 0600 密钥文件路径,正文无 DSN |
| 路径 | `paths`: `{data_root, artifacts_root, workspaces_root, plugins_dir}` | 绝对路径;I2 要求变更端口/程序不动这些 |
| 监听 | `listen`: `{addr, port}` | 显式覆盖白名单字段 |
| 配置 | `config`: `{file}` | 现 `.svc/<name>.json` 引用 |
| 依赖 | `deps`: `{required:[], optional:[]}` | U2 依赖图输入 |
| 恢复 | `recovery`: `{on_boot, watchdog}` | 策略位,U2/U7 消费 |
| 身份锚 | `identity`: `{db_marker:{expected_instance_id}, data_marker:{path}}` | §5 校验目标 |
| 机密 | `secrets`: `[{path, keys[], perms}]` | 引用清单,供 U6 备份范围 |
**v1.0 实施映射(I2,2026-10-12)**:`scripts/svc_manifest.py` 以「`identity` 锚点(data_root/db_mode/db_ref,冻结)+ `kv` 权威键值映射(svc.env 全量键,派生来源)+ `desired` 期望态」三件套承载上表全部不变量;§3 的完整 typed 分区(program/listen/config/deps/recovery/secrets)随 U2 依赖图与 U6 备份合同落地时扩展,扩展只增字段不改语义。`dsn_ref` 机密迁出同属 U6。
## 4. 写路径与并发
- 所有 manifest 写与 start/stop/restart/deploy/update/recover 在该实例的 `.start.lock` 内串行(复用 U2 已建锁,不新造锁)。
- 提交流程:整份读改 → 校验 → 写 `manifest.json.new` → fsync 文件 → `rename` 覆盖 → fsync 目录 →(派生 svc.env 同法)。revision 在 candidate 内 +1;rename 前复核在位 revision,不一致则丢弃 candidate 重读重试(防御性 CAS)。
- 禁止对 manifest 逐键写;迁移期兼容层 `svc_set` 语义改为"整份读-改-写"。
## 5. 解析顺序与启动校验
- 解析:CLI 显式覆盖(仅 `listen.*`、`program.source` 等白名单字段)> manifest > 环境变量(迁移后仅作显式 override 记录,不再隐式成为下次事实)。
- 启动校验(全部通过才 exec):
1. schema/revision 合法且 revision ≥ 在位值;
2. **DB marker**:PG 专用表 `agent_system_identity(instance_id, dbref, schema_version, created_at)`;SQLite 同表。首建仅发生在 `svc init`;常规启动要求 manifest.instance_id 与 marker 匹配,缺失/不匹配即阻塞(I4,对齐 resolve_db_config 现有"绑定失效拒绝启动")。
3. **数据根 marker**:`<data_root>/.agentx/data-marker.json`(instance_id + roots 清单摘要);不匹配/丢失阻塞。
4. `dsn_ref` 指向文件存在且权限 ≤0600。
- 显式入口:`svc init`(首次:创建 manifest+双 marker,要求目标为空);`svc restore`(灾难恢复:新目标装配 manifest+marker,走 U6 验证合同)。`start` 永不隐式 init。
## 6. 一次性迁移
lock → 读全部 svc.env 键 → 构造 manifest v1(rev=1) → §4 原子写 → 原 svc.env 更名 `svc.env.legacy`(只读留存)→ 派生新 svc.env。kill 窗口语义:只存在 legacy(未迁)或完整 v1+legacy(已迁),两者并存时以 manifest 为准并幂等补齐派生;无第三态。
## 7. 现状刻画回归(实施 I2 的前置测试计划)
`tests/test_svc_identity.py`,桩二进制+临时目录模式沿 `tests/test_svc_recover.py`:
| # | 场景 | 判据 |
|---|---|---|
| T1 | 裸 start→stop→start×2 | 同一 DB 标识与数据根(断言 svc.env/迁移后 manifest 不变、marker 不重建) |
| T2 | 只改端口再启停 | dbref/data/plugins 组合逐字节不变 |
| T3 | 单独 data 覆盖 | 按合同显式生效或整体拒绝,无半套路径 |
| T4 | 迁移窗口注入 kill(写 .new 后 rename 前) | 下次启动只读到完整旧或完整新 |
| T5 | 缺凭据/错 dbref/删 marker | 拒绝启动,退出码非 0,不产生新库/新 marker |
| T6 | 更换 exec 路径与 cwd 调用 svc | 身份字段不变(对齐 r2 U1 验收新增条目) |
真实 PG 专项(marker SQL、跨库身份)单列隔离用例,沿用 test_svc_recover 的桩 http/健康探测 + 真实 pg 实例环境标记;缺环境时 NOT_RUN。
## 8. 验收映射与未决
r2 U1 验收五条 ↔ 本文:连续裸启停(§5/I1)、只改端口(I2/§3 paths)、data 覆盖(§5 白名单+T3)、迁移 kill(§6/T4)、缺凭据错库丢 marker(§5.2-4/T5);外加程序/cwd(T6)。
已定案(2026-10-12“按建议办”):(1) `instance_id` 生成规则建议 `svc-<group>-<随机12>` 创建即冻结;(2) dsn 明文迁 `dsn_ref` 的备份影响(U6 范围);(3) manifest 与 `.svc/<name>.json` 应用配置的边界——本设计仅引用不合并;(4) Redis 等 optional 依赖在 deps 中的表达粒度。
+96 -8
View File
@@ -26,6 +26,7 @@
# scripts/svc.sh recover [name] [--quiet] 自恢复:把 autostart=1 但已死的服务重新拉起
# (崩溃/OOM 被杀/重启后由 watchdog 定时调用)
# scripts/svc.sh watchdog install|remove|status 自恢复看护:systemd 用户级 timer(每分钟 recover)
# scripts/svc.sh <name> init 显式建立/查看实例身份(manifest+数据 marker;U1)
# scripts/svc.sh help [主题] 分主题详细帮助
#
# ════════════════════ 部署来源(agent/static 的 deploy)════════════════════
@@ -815,6 +816,17 @@ record_state() {
[ $# -gt 0 ] && printf '%s\n' "$@"
} > "$tmp"
chmod 600 "$tmp"
# U1 身份:manifest 治理下的整份提交(bulk-set 走 temp→fsync→rename 原子链,
# revision 递增,svc.env 由 manifest 派生);失败保留旧配置,不产生半写态。
if manifest_ok; then
if python3 "$MANIFEST_PY" "$ENV_DIR" bulk-set < "$tmp"; then
rm -f "$tmp"
return 0
fi
c_err "[$ENV_NAME] manifest 提交失败,保留旧配置(详见上方错误)"
rm -f "$tmp"
return 1
fi
mv -f "$tmp" "$ENV_DIR/svc.env"
}
@@ -882,10 +894,55 @@ svc_unlock() {
return 0
}
# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 1,
# 显式 stop 移除;进程自行死亡不动——recover 据此判断是否拉回)。
svc_mark_autostart() { svc_set_key autostart 1 >/dev/null 2>&1 || true; }
svc_clear_autostart() { svc_del_key autostart 2>/dev/null || true; }
# ---- U1 实例身份 manifest(设计:docs/recovery/u1-identity-manifest.md,已批准)----
# manifest.json 为启动权威(identity 锚点冻结数据根/数据库身份;kv 为权威键值;
# desired 为期望态),svc.env 降为派生只读视图,未迁移实例沿用旧路径。
MANIFEST_PY="$ROOT/scripts/svc_manifest.py"
manifest_ok() {
[ -f "$ENV_DIR/manifest.json" ] && command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ]
}
# svc_manifest_migrate:锁内一次性迁移(幂等)。已有数据视为"领养"——按当前数据根
# 建立 marker;数据根不存在或携带他实例 marker 时拒绝(不新建空系统掩盖丢数据),
# 失败保持 legacy 治理继续运行。
svc_manifest_migrate() {
[ -f "$ENV_DIR/svc.env" ] && [ ! -f "$ENV_DIR/manifest.json" ] || return 0
command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || {
c_warn "[$ENV_NAME] 跳过 manifest 迁移:需 python3 与 scripts/svc_manifest.py"
return 0
}
local out rc=0
out="$(python3 "$MANIFEST_PY" "$ENV_DIR" migrate 2>&1)" || rc=$?
if [ "$rc" -eq 0 ]; then
c_ok "[$ENV_NAME] manifest:$(printf '%s' "$out" | tail -1)"
return 0
fi
if [ "$rc" -eq 4 ]; then
# 数据根携带他实例 marker:身份治理冲突(如 manifest 丢失后试图重建)——
# 中止而非降级 legacy(I4:丢身份是事件,禁止新建空系统掩盖)。
c_err "[$ENV_NAME] 身份治理冲突,中止:$(printf '%s' "$out" | tail -1)"
return 1
fi
c_err "[$ENV_NAME] manifest 迁移失败(保持旧治理):$(printf '%s' "$out" | tail -1)"
return 0
}
# svc_manifest_verify:启动前身份校验——数据 marker 存在且 instance_id 匹配;
# 生效 --data / 数据库绑定与冻结锚点一致。失败即拒绝启动(KTD1/KTD2,I4)。
svc_manifest_verify() {
manifest_ok || return 0
local out
if ! out="$(python3 "$MANIFEST_PY" "$ENV_DIR" verify \
--data "$ENV_DATA" --db-mode "${DB_MODE:-}" --db-ref "${DB_REF:-}" 2>&1)"; then
c_err "[$ENV_NAME] 身份校验失败,拒绝启动:$(printf '%s' "$out" | tail -1)"
c_err " 数据根/数据库绑定是冻结身份;换根/重绑须显式迁移或登记命令(docs/recovery/u1-identity-manifest.md §5)"
return 1
fi
return 0
}
# svc_mark_autostart / svc_clear_autostart:期望运行态登记(成功 start 置 running,
# 显式 stop 置 stopped;进程自行死亡不动——recover 据 svc.env 派生的 autostart 判断)。
svc_mark_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired running >/dev/null 2>&1 && return 0; svc_set_key autostart 1 >/dev/null 2>&1 || true; }
svc_clear_autostart() { manifest_ok && python3 "$MANIFEST_PY" "$ENV_DIR" set-desired stopped >/dev/null 2>&1 && return 0; svc_del_key autostart 2>/dev/null || true; }
check_port_free() {
local owner; owner="$(port_owner)"
@@ -1832,6 +1889,9 @@ agent_do_start() {
hosts="$(env_var "$ENV_NAME" ALLOWED_HOSTS)"
[ -n "$insecure" ] && guard_env+=("AGENT_SYSTEM_INSECURE_LISTEN=$insecure")
[ -n "$hosts" ] && guard_env+=("AGENT_SYSTEM_ALLOWED_HOSTS=$hosts")
# U1 身份校验:manifest 治理下,数据 marker 与冻结锚点不符即拒绝启动(在一切
# 副作用之前;登记不变更)。
svc_manifest_verify || { hist_event start fail "" "manifest 身份校验失败"; return 1; }
# 预检(快速拦截):非回环地址且认证判定为关时提前拒绝,报错可操作——
# 不等二进制 listen guard 以"进程即退"暴露;二进制守卫仍是权威。
# 认证判定依据统一解析的最终有效 DSN 与 auth(含环境变量与配置文件来源),
@@ -2261,10 +2321,14 @@ check_port_available() {
return 0
}
# svc_set_key KEY VALUE:改写当前服务 svc.env 的单键(先读后写 + 临时文件原子替换,
# 避免重定向截断与半写)。
# svc_set_key KEY VALUE:改写当前服务 svc.env 的单键。manifest 治理下经 manifest
# 整份提交(原子 + revision);legacy 路径保持先读后写 + 临时文件原子替换。
svc_set_key() {
local key="$1" val="$2" rest tmp
if manifest_ok; then
python3 "$MANIFEST_PY" "$ENV_DIR" set "$key" "$val" && return 0
c_warn "[$ENV_NAME] manifest set 失败,回退 legacy 单键写"
fi
[ -f "$ENV_DIR/svc.env" ] || { c_err "svc.env 不存在:$ENV_DIR"; return 1; }
rest="$(grep -v "^$key=" "$ENV_DIR/svc.env" || true)"
tmp="$ENV_DIR/svc.env.tmp.$$"
@@ -2274,9 +2338,14 @@ svc_set_key() {
return 0
}
# svc_del_key KEY:从当前服务 svc.env 删除单键(键不存在则无操作)。
# svc_del_key KEY:从当前服务 svc.env 删除单键(键不存在则无操作)。manifest 治理下
# 同样经 manifest 整份提交。
svc_del_key() {
local key="$1"
if manifest_ok; then
python3 "$MANIFEST_PY" "$ENV_DIR" del "$key" && return 0
c_warn "[$ENV_NAME] manifest del 失败,回退 legacy 单键写"
fi
[ -f "$ENV_DIR/svc.env" ] || return 0
{ grep -v "^$key=" "$ENV_DIR/svc.env" || true; } > "$ENV_DIR/svc.env.tmp"
mv "$ENV_DIR/svc.env.tmp" "$ENV_DIR/svc.env"
@@ -3908,7 +3977,12 @@ main() {
# 生命周期动作持 .start.lock(wait 模式;进程退出自动释放)——deploy 全程含
# 构建窗口,防 recover/并发 start 在停机-换产物间隙抢跑。只读动作不加锁。
case "$action" in
deploy|stop|start|restart) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;;
deploy|stop|start|restart|init) svc_lock wait || { c_err "[$ENV_NAME] 无法获取生命周期锁"; exit 1; } ;;
esac
# U1 身份:锁内一次性迁移(幂等;含既有数据领养 marker)。归属冲突(rc=4)中止;
# 其余失败保持 legacy 治理继续运行。
case "$action" in
start|restart|deploy|init) svc_manifest_migrate || exit 1 ;;
esac
case "$action" in
deploy)
@@ -3920,6 +3994,20 @@ main() {
stop) do_stop ;;
start) do_start ;;
restart) do_restart ;;
init)
# 显式建立/查看身份(manifest+数据 marker)。迁移钩子已先行:有 svc.env 无
# manifest 时 migrate 已建好,此处只报告;无 svc.env 引导走首次 start。
command -v python3 >/dev/null 2>&1 && [ -f "$MANIFEST_PY" ] || {
c_err "[$ENV_NAME] init 需要 python3 与 scripts/svc_manifest.py"; exit 2; }
if [ -f "$ENV_DIR/manifest.json" ]; then
c_ok "[$ENV_NAME] 已在 manifest 治理下(init 幂等):"
elif [ -f "$ENV_DIR/svc.env" ]; then
python3 "$MANIFEST_PY" "$ENV_DIR" init \
|| { c_err "[$ENV_NAME] init 失败(数据根冲突等,见上)"; exit 1; }
else
c_err "[$ENV_NAME] 尚无 svc.env:先用 start 完成首次登记;init 用于显式化已有实例"; exit 2
fi
python3 "$MANIFEST_PY" "$ENV_DIR" status ;;
status) env_status_detail ;;
logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;;
esac
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""svc 实例身份 manifest(U1):manifest.json 为启动权威,svc.env 为派生只读视图。
设计合同:docs/recovery/u1-identity-manifest.md(已批准 2026-10-12)。
落地说明:v1.0 以「identity 锚点(数据根/数据库身份,冻结)+ kv 权威键值映射 +
desired 期望态」承载 KTD1/KTD2 的全部不变量;完整 typed 分区随 U2/U6 演进。
用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|set-desired|verify> [参数]
不变量(对应设计 §1):
I3 原子性 —— 整份 temp→fsync→rename→dirsync,任意时刻只有完整旧版或新版。
I4 阻塞 —— manifest 存在时,数据 marker 缺失/身份不符即拒绝,不新建空系统。
I5 机密 —— 本文件不新增任何机密;kv 沿袭 svc.env 既有内容(DSN 迁出属 U6)。
"""
import json
import os
import secrets
import sys
import time
SCHEMA = 1
MANIFEST = "manifest.json"
DERIVED = "svc.env"
MARKER_REL = os.path.join(".agentx", "data-marker.json")
PROTECTED = ("db", "dbref", "dsn", "conffile") # 显式持久化写入可重锚;启动期漂移由 verify 拦截
def die(msg, code=1):
print("[manifest] %s" % msg, file=sys.stderr)
sys.exit(code)
def now_iso():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def atomic_write(path, text, mode=0o600):
d = os.path.dirname(path) or "."
os.makedirs(d, exist_ok=True)
tmp = path + ".new"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp, mode)
os.replace(tmp, path)
dfd = os.open(d, os.O_DIRECTORY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
def read_kv(env_dir):
kv = {}
path = os.path.join(env_dir, DERIVED)
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
for line in f:
line = line.rstrip("\n")
if line and "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
kv[k] = v
return kv
def load(env_dir):
path = os.path.join(env_dir, MANIFEST)
if not os.path.exists(path):
return None
try:
with open(path, encoding="utf-8") as f:
m = json.load(f)
except Exception as e: # noqa: BLE001 —— 损坏必须阻塞,不许猜测重建
die("manifest 损坏(拒绝猜测,人工处置):%s: %s" % (path, e), 2)
if m.get("schema") != SCHEMA:
die("manifest schema 不受支持:%s(当前 %s)" % (m.get("schema"), SCHEMA), 2)
for field in ("revision", "instance_id", "identity", "kv", "desired"):
if field not in m:
die("manifest 缺少必需字段 %s(拒绝猜测)" % field, 2)
return m
def derive_text(m):
kv = dict(m["kv"])
if m["desired"] == "running":
kv["autostart"] = "1"
else:
kv.pop("autostart", None)
return "".join("%s=%s\n" % (k, v) for k, v in kv.items())
def save(env_dir, m):
atomic_write(os.path.join(env_dir, MANIFEST),
json.dumps(m, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
atomic_write(os.path.join(env_dir, DERIVED), derive_text(m))
def data_root_of(kv, env_dir):
kind = kv.get("kind", "agent")
if kind == "static":
return os.path.abspath(env_dir) # static 无数据目录语义,锚定实例目录本身
parts = (kv.get("exec") or "").split()
for i, w in enumerate(parts):
if w == "--data" and i + 1 < len(parts):
return os.path.abspath(parts[i + 1])
return os.path.abspath(os.path.join(env_dir, "pgdata" if kind == "postgres" else "data"))
def db_anchor_of(kv):
mode = kv.get("db", "")
if mode == "pg":
return "pg", kv.get("dbref", "")
if mode == "sqlite":
return "sqlite", kv.get("dsn", "") or "default"
return "", ""
def marker_path(data_root):
return os.path.join(data_root, MARKER_REL)
def write_marker(data_root, instance_id):
atomic_write(marker_path(data_root), json.dumps({
"schema": 1,
"instance_id": instance_id,
"data_root": data_root,
"created_at": now_iso(),
}, ensure_ascii=False, indent=2) + "\n")
def read_marker(data_root):
path = marker_path(data_root)
if not os.path.exists(path):
return None
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception: # noqa: BLE001 —— marker 损坏等同身份不可证
return {"schema": -1}
def reanchor(env_dir, m):
"""按显式持久化写入重算身份锚点;数据根变化时在新根补写 marker(目录存在才写)。"""
ident = dict(m["identity"])
ident["data_root"] = data_root_of(m["kv"], env_dir)
ident["db_mode"], ident["db_ref"] = db_anchor_of(m["kv"])
if ident != m["identity"]:
if os.path.isdir(ident["data_root"]):
write_marker(ident["data_root"], m["instance_id"])
m["identity"] = ident
return m
def build(env_dir, kv, allow_create):
kind = kv.get("kind", "agent")
name = kv.get("env") or os.path.basename(os.path.abspath(env_dir))
data_root = data_root_of(kv, env_dir)
if not os.path.isdir(data_root):
if not allow_create:
die("数据根不存在且非显式 init,拒绝建立身份(防新建空系统掩盖丢数据):%s" % data_root, 3)
os.makedirs(data_root, exist_ok=True)
existing = read_marker(data_root)
if existing is not None:
if existing.get("instance_id") != kv.get("instance_id"):
die("数据根已携带其他实例 marker(instance_id=%s):数据归属冲突,拒绝迁移/初始化,人工处置"
% existing.get("instance_id"), 4)
instance_id = existing["instance_id"]
else:
instance_id = kv.get("instance_id") or "svc-%s-%s" % (name, secrets.token_hex(6))
write_marker(data_root, instance_id)
db_mode, db_ref = db_anchor_of(kv)
m = {
"schema": SCHEMA,
"revision": 1,
"instance_id": instance_id,
"service_group": name,
"created_at": now_iso(),
"desired": "running" if kv.get("autostart") == "1" else "stopped",
"identity": {"data_root": data_root, "db_mode": db_mode, "db_ref": db_ref},
"kv": {k: v for k, v in kv.items() if k != "autostart"},
}
save(env_dir, m)
return m
def cmd_migrate(env_dir):
if load(env_dir) is not None:
print("manifest 已存在,幂等跳过")
return 0
if not os.path.exists(os.path.join(env_dir, DERIVED)):
die("svc.env 不存在:先完成首次登记(start)或使用 init", 3)
m = build(env_dir, read_kv(env_dir), allow_create=False)
print("已迁移 rev=1 instance_id=%s data=%s" % (m["instance_id"], m["identity"]["data_root"]))
return 0
def cmd_init(env_dir):
if load(env_dir) is not None:
die("manifest 已存在(init 仅用于显式建立;重建需人工处置)", 3)
kv = read_kv(env_dir)
m = build(env_dir, kv, allow_create=True)
print("已建立 rev=1 instance_id=%s data=%s" % (m["instance_id"], m["identity"]["data_root"]))
return 0
def cmd_status(env_dir):
m = load(env_dir)
if m is None:
print(json.dumps({"exists": False}, ensure_ascii=False))
return 0
print(json.dumps({
"exists": True, "revision": m["revision"], "instance_id": m["instance_id"],
"desired": m["desired"], "identity": m["identity"],
}, ensure_ascii=False))
return 0
def mutate(env_dir, fn):
m = load(env_dir)
if m is None:
die("manifest 不存在:先 migrate/init", 3)
before = json.dumps(m["identity"], sort_keys=True)
fn(m)
m["revision"] = int(m["revision"]) + 1
m = reanchor(env_dir, m)
save(env_dir, m)
if before != json.dumps(m["identity"], sort_keys=True):
print("[manifest] 身份锚点已随显式持久化写入更新(data=%s db=%s/%s)"
% (m["identity"]["data_root"], m["identity"]["db_mode"], m["identity"]["db_ref"]))
return 0
def cmd_set(env_dir, key, value):
return mutate(env_dir, lambda m: m["kv"].__setitem__(key, value))
def cmd_del(env_dir, key):
def drop(m):
m["kv"].pop(key, None)
return mutate(env_dir, drop)
def cmd_bulk_set(env_dir):
kv = {}
for line in sys.stdin.read().splitlines():
if line and "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
kv[k] = v
kv.pop("autostart", None) # desired 独占期望态
def replace(m):
m["kv"] = kv
return mutate(env_dir, replace)
def cmd_set_desired(env_dir, state):
if state not in ("running", "stopped"):
die("desired 取值仅 running|stopped", 2)
return mutate(env_dir, lambda m: m.__setitem__("desired", state))
def cmd_verify(env_dir, data_root=None, db_mode=None, db_ref=None):
m = load(env_dir)
if m is None:
die("manifest 缺失:本实例已进入 manifest 治理,缺失即事件(先 init/人工处置,禁止静默重建)", 4)
ident = m["identity"]
marker = read_marker(ident["data_root"])
if marker is None or marker.get("schema") == -1:
die("数据 marker 缺失/损坏:data=%s(数据归属不可证即拒绝启动,防新建空库掩盖)"
% marker_path(ident["data_root"]), 5)
if marker.get("instance_id") != m["instance_id"]:
die("数据 marker 与 manifest 身份不符:marker=%s manifest=%s(数据被换过或身份被改,人工处置)"
% (marker.get("instance_id"), m["instance_id"]), 5)
if data_root is not None and os.path.abspath(data_root) != ident["data_root"]:
die("数据根漂移:生效 %s ≠ 锚定 %s;数据根是冻结身份,换根须显式迁移(docs/recovery/u1-identity-manifest.md §5)"
% (os.path.abspath(data_root), ident["data_root"]), 6)
if db_mode and ident["db_mode"] and db_mode != ident["db_mode"]:
die("数据库模式漂移:生效 %s ≠ 锚定 %s" % (db_mode, ident["db_mode"]), 6)
if db_mode and ident["db_mode"] and db_ref is not None and (db_ref or "") != ident["db_ref"]:
die("数据库绑定漂移:生效 %s ≠ 锚定 %s(重绑须经显式登记命令)" % (db_ref or "<空>", ident["db_ref"]), 6)
print("身份校验通过 instance_id=%s data=%s db=%s/%s"
% (m["instance_id"], ident["data_root"], ident["db_mode"] or "-", ident["db_ref"] or "-"))
return 0
def main(argv):
if len(argv) < 3:
die("用法: svc_manifest.py ENV_DIR <migrate|init|status|set|del|bulk-set|set-desired|verify> [参数]", 2)
env_dir, cmd = argv[1], argv[2]
args = argv[3:]
if cmd == "migrate":
return cmd_migrate(env_dir)
if cmd == "init":
return cmd_init(env_dir)
if cmd == "status":
return cmd_status(env_dir)
if cmd == "set" and len(args) == 2:
return cmd_set(env_dir, args[0], args[1])
if cmd == "del" and len(args) == 1:
return cmd_del(env_dir, args[0])
if cmd == "bulk-set":
return cmd_bulk_set(env_dir)
if cmd == "set-desired" and len(args) == 1:
return cmd_set_desired(env_dir, args[0])
if cmd == "verify":
data_root = db_mode = db_ref = None
it = iter(args)
for a in it:
if a == "--data":
data_root = next(it, None)
elif a == "--db-mode":
db_mode = next(it, None)
elif a == "--db-ref":
db_ref = next(it, None)
return cmd_verify(env_dir, data_root, db_mode, db_ref)
die("未知命令或参数不全:%s %s" % (cmd, " ".join(args)), 2)
if __name__ == "__main__":
sys.exit(main(sys.argv))
+235
View File
@@ -0,0 +1,235 @@
"""svc.sh 实例身份回归(U1 设计 §7 T1–T6,manifest 治理)。
覆盖:连续裸启停身份稳定(T1)、只改端口锚点不动(T2)、数据根漂移拒绝(T3)、
迁移窗口/损坏阻塞不猜测(T4)、marker 缺失/外宾/manifest 丢失均为事件(T5)、
cwd 无关性(T6)、原子写崩溃窗口单元级(原文件不受损)。
桩二进制与夹具复用 tests/test_svc_recover.py。真实 PG 身份专项(DB marker SQL 层)
NOT_RUN:需真实实例,SVC_TEST_PG=1 且具备环境时另行执行,本文件不模拟。
"""
import importlib.util
import json
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
tsr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tsr)
class IdentityTests(tsr.SvcAutoRecoverTests):
def setUp(self):
super().setUp()
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
self.root / "scripts" / "svc_manifest.py")
# ---- 夹具 ----
def manifest(self, name):
p = self.runtime(name) / "manifest.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def marker(self, name):
m = self.manifest(name)
if not m:
return None
p = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def running(self, name):
pid = self.pid_of(name)
if not pid:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
# ---- T1 连续裸启停:同一身份与数据根 ----
def test_T1_bare_restart_stable_identity(self):
self.seed_agent("id1", 9431)
self.start_ok("id1")
m1 = self.manifest("id1")
self.assertIsNotNone(m1, "start 应完成 manifest 迁移")
iid = m1["instance_id"]
self.assertTrue(iid.startswith("svc-id1-"), iid)
mk = self.marker("id1")
self.assertEqual(mk["instance_id"], iid)
data_before = m1["identity"]["data_root"]
exec_before = m1["kv"]["exec"]
self.svc("id1", "stop")
self.start_ok("id1")
m2 = self.manifest("id1")
self.assertEqual(m2["instance_id"], iid, "裸启停不得换身份")
self.assertEqual(m2["identity"]["data_root"], data_before)
self.assertEqual(m2["kv"]["exec"], exec_before, "裸启停不得漂移 exec(数据/插件根)")
self.assertGreaterEqual(m2["revision"], m1["revision"], "整份提交 revision 单调")
self.assertTrue(self.running("id1"))
self.svc("id1", "stop")
# ---- T2 只改端口:数据/DB 锚点不动 ----
def test_T2_port_change_keeps_anchors(self):
self.seed_agent("id2", 9432)
self.start_ok("id2")
anchor = self.manifest("id2")["identity"]
self.svc("id2", "stop")
result = self.svc("id2", "start", "--port", "9433")
self.assertIn("已启动", result.stdout)
m2 = self.manifest("id2")
self.assertEqual(m2["identity"]["data_root"], anchor["data_root"], "改端口不得动数据根")
self.assertEqual(m2["identity"]["db_mode"], anchor["db_mode"])
self.assertEqual(m2["identity"]["db_ref"], anchor["db_ref"])
self.assertIn("9433", m2["kv"]["addr"])
self.svc("id2", "stop")
# ---- T3 数据根漂移:拒绝启动,不新建空系统 ----
def test_T3_data_drift_rejected(self):
self.seed_agent("id3", 9434)
self.start_ok("id3")
self.svc("id3", "stop")
other = self.root / "other-data"
other.mkdir()
result = self.svc("id3", "start", "--data", str(other), check=False)
self.assertNotEqual(result.returncode, 0, "数据根漂移必须拒绝")
self.assertTrue(any(k in result.stdout for k in ("身份校验失败", "数据根漂移")),
result.stdout)
self.assertFalse(self.running("id3"), "拒绝后不得有服务进程")
self.assertEqual(self.manifest("id3")["identity"]["data_root"],
str(self.runtime("id3") / "data"), "锚点不得被污染")
# ---- T4 迁移窗口与损坏:不猜测 ----
def test_T4a_leftover_new_file_ignored(self):
self.seed_agent("id4", 9435)
# 模拟 rename 前崩溃残留:.new 半成品必须被忽略/覆盖,不影响迁移结果
(self.runtime("id4") / "manifest.json.new").write_text("{ truncated")
self.start_ok("id4")
m = self.manifest("id4")
self.assertIsNotNone(m)
self.assertEqual(m["schema"], 1)
self.svc("id4", "stop")
def test_T4b_corrupt_manifest_blocks(self):
self.seed_agent("id5", 9436)
self.start_ok("id5")
self.svc("id5", "stop")
(self.runtime("id5") / "manifest.json").write_text("{ not json")
result = self.svc("id5", "start", check=False)
self.assertNotEqual(result.returncode, 0, "损坏 manifest 必须阻塞而非猜测重建")
self.assertIn("损坏", result.stdout)
self.assertFalse(self.running("id5"))
# ---- T5 marker/manifest 事件:缺一即阻塞,不自动新建 ----
def test_T5a_missing_marker_blocks(self):
self.seed_agent("id6", 9437)
self.start_ok("id6")
self.svc("id6", "stop")
m = self.manifest("id6")
mk = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
mk.unlink()
result = self.svc("id6", "start", check=False)
self.assertNotEqual(result.returncode, 0, "marker 缺失即事件")
self.assertIn("marker", result.stdout)
self.assertFalse(self.running("id6"))
def test_T5b_foreign_marker_blocks(self):
self.seed_agent("id7", 9438)
self.start_ok("id7")
self.svc("id7", "stop")
m = self.manifest("id7")
mk = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
data = json.loads(mk.read_text(encoding="utf-8"))
data["instance_id"] = "svc-other-zzzzzzzzzzzz"
mk.write_text(json.dumps(data), encoding="utf-8")
result = self.svc("id7", "start", check=False)
self.assertNotEqual(result.returncode, 0, "marker 身份不符即事件")
self.assertFalse(self.running("id7"))
def test_T5c_lost_manifest_blocks_not_rebuild(self):
self.seed_agent("id8", 9439)
self.start_ok("id8")
m0 = self.manifest("id8")
iid = m0["instance_id"]
marker_path = Path(m0["identity"]["data_root"]) / ".agentx" / "data-marker.json"
self.svc("id8", "stop")
(self.runtime("id8") / "manifest.json").unlink()
result = self.svc("id8", "start", check=False)
# manifest 丢失后 marker 仍在:重建会撞归属冲突 → 中止(I4),不得 legacy 复活
self.assertNotEqual(result.returncode, 0, "丢 manifest 不得静默重建/legacy 复活")
self.assertTrue(any(k in result.stdout for k in ("归属冲突", "身份治理冲突")),
result.stdout)
self.assertFalse(self.running("id8"))
# marker 保持原身份,未被新随机身份覆盖
mk = json.loads(marker_path.read_text(encoding="utf-8"))
self.assertEqual(mk["instance_id"], iid)
# ---- T6 cwd 无关:从别处调用身份不变 ----
def test_T6_cwd_independent_identity(self):
self.seed_agent("id9", 9440)
self.start_ok("id9")
iid = self.manifest("id9")["instance_id"]
self.svc("id9", "stop")
elsewhere = tempfile.mkdtemp(prefix="svc-elsewhere-")
self.addCleanup(shutil.rmtree, elsewhere, True)
proc = subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), "id9", "start"],
cwd=elsewhere, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
self.assertEqual(proc.returncode, 0, proc.stdout)
self.assertIn("已启动", proc.stdout)
self.assertEqual(self.manifest("id9")["instance_id"], iid, "换 cwd 不得换身份")
self.svc("id9", "stop")
# ---- 真实 PG 专项:显式 NOT_RUN 标注(不模拟) ----
def test_Z_real_pg_not_run(self):
# DB marker(agent_system_identity 表)与跨库身份断言属真实 PG 专项,
# 由隔离真实实例执行;本桩套件明确 NOT_RUN,不以桩冒充(设计 §7)。
self.skipTest("真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG 环境另行执行)")
class AtomicWriteTests(unittest.TestCase):
"""崩溃窗口单元级:os.replace 失败时原文件必须完好(I3 半写不可能)。"""
def test_replace_failure_keeps_original(self):
spec = importlib.util.spec_from_file_location(
"sm", SOURCE / "scripts" / "svc_manifest.py")
sm = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sm)
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "m.json")
sm.atomic_write(path, '{"v": 1}\n')
real_replace = os.replace
calls = {"n": 0}
def boom(a, b):
calls["n"] += 1
raise OSError("simulated crash before rename completes")
os.replace = boom
try:
try:
sm.atomic_write(path, '{"v": 2}\n')
except OSError:
pass
finally:
os.replace = real_replace
self.assertEqual(calls["n"], 1)
with open(path, encoding="utf-8") as f:
self.assertEqual(f.read(), '{"v": 1}\n', "rename 前崩溃必须保留完整旧版")
if __name__ == "__main__":
unittest.main()