Merge origin/main into U1 account-binding; keep user+backup actions
Why: Dev #429 conflicted on scripts/svc.sh (U1 user vs main backup) and Backend gitlink. Evidence: conflict hunks resolved; actions include init|user|backup|backups Impact: PR #429 should become mergeable after push; both U1 binding and agent backup entrypoints preserved. Refs: Dev pulls/429; main backup #417; Backend #171 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ __pycache__/
|
||||
/data-test/
|
||||
.zcode/plans/
|
||||
runtime/
|
||||
runtime-backups/
|
||||
.svc.env
|
||||
|
||||
# 运行时环境文件(含密钥,不入库;svc-envfile 任务约定)
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
# feature/adminzone-plugin-sections 任务卡
|
||||
|
||||
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:weichen
|
||||
任务主记录:PR #414 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/414(merge 28af351fb3);状态:已完成(回填);owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
## 结果与验证
|
||||
|
||||
- 结果:
|
||||
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
|
||||
@@ -287,3 +287,9 @@
|
||||
"receipt_sha256": "09f6e32908df5414897575986f6cafb258dbb5b466d0bc5a384615170c89a03f"
|
||||
}
|
||||
```
|
||||
|
||||
## 闭合记录(card-autoclose 回填)
|
||||
|
||||
- 依据:merge 28af351fb32185f38c0228c4c2040e854c6ecd50(PR #414)已包含于 origin/main,分支成果已落盘;本段为合入后回填。
|
||||
- 合并主题:Merge pull request 'Host plugin-declared admin-zone sections (WP3-I3/KTD4)' (#414) from feature/adminzone-plugin-sections into main
|
||||
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# feature/genui-spec-repair 任务卡
|
||||
|
||||
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:weichen
|
||||
任务主记录:PR #416 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/416(merge 37e3229538);状态:已完成(回填);owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
## 结果与验证
|
||||
|
||||
- 结果:
|
||||
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
|
||||
@@ -203,3 +203,9 @@
|
||||
"receipt_sha256": "8f966ac79cb51f554da022215d6f1d968240b66db8754c76247d2e3a02d6d8b7"
|
||||
}
|
||||
```
|
||||
|
||||
## 闭合记录(card-autoclose 回填)
|
||||
|
||||
- 依据:merge 37e3229538d64867a6a074020a072da925244fcc(PR #416)已包含于 origin/main,分支成果已落盘;本段为合入后回填。
|
||||
- 合并主题:Merge pull request 'Bump backend pin: genui spec repair and subagent present (backend #166)' (#416) from feature/genui-spec-repair into main
|
||||
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
|
||||
+27
-15
@@ -1,6 +1,6 @@
|
||||
# feature/recovery-u1-instance-identity 任务卡
|
||||
|
||||
任务主记录:方案 r2 §7 U1(modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md,已批准,随 Documents #75 合入);设计 v0.1:docs/recovery/u1-identity-manifest.md + docs/recovery/u1-data-ownership.md;主记录 PR:Dev #415(CI 为验收 GATE);owner:weichen
|
||||
任务主记录:PR #415 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/415(merge af9c2de663);设计 v0.1:docs/recovery/u1-identity-manifest.md + docs/recovery/u1-data-ownership.md;主记录 PR:Dev #415(CI 为验收 GATE);owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- 授权与交付终点:用户批准方案 r2(2026-10-12)并指示启动 U1;设计经“按建议办”批准后实施 I2。2026-10-12 追加授权:推送本任务分支并创建 PR(CI 为正式验收 GATE,绿前不合入;合入由 owner 执行)。
|
||||
- 交付模式:manual(逐阶段人工评审);明确授权后可记为 authorized-continuous
|
||||
- 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权)
|
||||
- 当前阶段:实施与自检(I2 代码完成:manifest 层+启动校验+init+T1–T6 全绿,待正式验收与代码 Review);设计已批准(2026-10-12“按建议办”)
|
||||
- 当前阶段:已完成(I1+I2 合入 main;GATE=PR #415 CI 双绿于 HEAD 6d453ee;剩余实施增量 I3 另开任务 feature/recovery-u1-db-identity)
|
||||
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
|
||||
|
||||
## 目标与非目标
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## 关联方向与规划
|
||||
|
||||
- 适用状态:服务重启与全量数据恢复方向(方案 r2 已批准,2026-10-12)
|
||||
- 适用状态:已完成(回填)
|
||||
- 主记录路径/版本:modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md r2;U2 首切片已由 svc-autorecover 合入 main(PR #396/#401)
|
||||
- 对应里程碑:r2 U1(批次A);R2/R3/R9
|
||||
|
||||
@@ -47,10 +47,10 @@
|
||||
| 方案设计 | docs/recovery/u1-identity-manifest.md v0.1 | 已批准 | weichen | 2026-10-12 | 用户“按建议办”:四未决项按设计建议定案(instance_id=svc-<group>-<随机12>创建即冻结;dsn_ref 备份影响记 U6;与 .svc/<name>.json 仅引用不合并;deps required/optional 列表表达) |
|
||||
| 实施计划 | 待定 | 未评审 | — | — | — |
|
||||
| 实施与自检 | I2 @ c170939(同步后 ce78d59) | 已完成(SELF) | — | 2026-10-12 | 身份套件 21/21 + 治理套件 169/169 全绿(收据入卡);真实 PG 专项 NOT_RUN(缺环境,按设计 §7 显式标注) |
|
||||
| 正式测试与验收 | 同步后 HEAD 重跑 | 本地 SELF 通过;GATE 待 PR CI | 待 weichen 复核 | — | 全量 make test+make build 以 PR HEAD CI 为权威(AGENTS 验证合同) |
|
||||
| 正式测试与验收 | PR #415 HEAD 6d453ee | GATE 通过(ci/test + ci/test-pg success) | weichen | 2026-10-12 | PR CI 为权威;本地全量 make test SELF exit 0 辅助 |
|
||||
| 代码 Review | 待定 | 未评审 | — | — | — |
|
||||
| 合入 | 待定 | 未评审 | — | — | — |
|
||||
| 合入核验与收口 | 待定 | 未评审 | — | — | — |
|
||||
| 合入 | merge af9c2de(含 6d453ee) | 已合入 | weichen | 2026-10-12 | PR #415 |
|
||||
| 合入核验与收口 | worktree recycled | 已收口(task-finish) | weichen | 2026-10-12 | TASK_WRITERS_STOPPED=1 make task-finish;远端分支已删 |
|
||||
|
||||
## 探索试验与迭代
|
||||
|
||||
@@ -71,11 +71,11 @@
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
## 结果与验证
|
||||
|
||||
- 结果:
|
||||
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
|
||||
- 影响与剩余项:
|
||||
- 结果:I1 设计 + I2 manifest/数据 marker/init/T1–T6 已合入 main(PR #415)。
|
||||
- 验证:GATE PASS(PR #415 CI:ci/test=success、ci/test-pg=success @ HEAD 6d453ee);真实 PG 身份表专项 NOT_RUN(属 I3)。
|
||||
- 影响与剩余项:**I3 未做**——真实库表 `agent_system_identity` + 真实 PG 用例;另开任务 `feature/recovery-u1-db-identity` 推进。
|
||||
|
||||
## 规划进展与下一步
|
||||
|
||||
@@ -89,14 +89,14 @@
|
||||
|
||||
## 规划进展与下一步
|
||||
|
||||
- 本次推进/剩余:I1+I2 完成(设计批准;svc_manifest.py 权威层、svc.sh 迁移/校验/init 接线、T1–T6 全绿);剩余:正式验收(含真实 PG 专项,缺环境标 NOT_RUN)→Review→推送/PR 待授权
|
||||
- 首选下一任务:本任务 I2;并行可启动 U3 生命周期设计(独立任务)
|
||||
- 前置条件/待确认:设计 v0.1 人工评审(含未决四项:instance_id 生成规则、dsn_ref 备份影响、与 .svc/<name>.json 边界、optional 依赖表达)
|
||||
- 本次推进/剩余:I1+I2 已合入;**剩余 I3**(`agent_system_identity` + 真实 PG 用例)转新任务。
|
||||
- 首选下一任务:`feature/recovery-u1-db-identity`(I3);U3 生命周期设计可并行。
|
||||
- 前置条件/待确认:无(设计 §5.2 已定案;用户已授权「继续推进」含 I3)。
|
||||
|
||||
## 合入与清理
|
||||
|
||||
- 合入记录:NOT_RUN(授权依据、PR、合入 SHA、验证来源)
|
||||
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
|
||||
- 合入记录:用户合入 PR #415(merge af9c2de);绿 HEAD `6d453ee`;GATE=PR CI(test + test-pg success)。
|
||||
- 清理记录:2026-10-12 授权继续收尾;`TASK_WRITERS_STOPPED=1 make task-finish` 回收 worktree 与本地/远端任务分支(observed_head=6d453ee);任务卡随闭合 PR #421 归档;**I3 残留转新任务**。
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
@@ -420,3 +420,15 @@
|
||||
"receipt_sha256": "44d1f990a1f41b14c2607be01aaf52cfb6717264f29f826cca53cf56e29e0cad"
|
||||
}
|
||||
```
|
||||
|
||||
## 闭合记录(card-autoclose 回填)
|
||||
|
||||
- 依据:merge af9c2de663be5ff102dcab7698464a645832f569(PR #415)已包含于 origin/main,分支成果已落盘;本段为合入后回填。
|
||||
- 合并主题:Merge pull request 'Implement U1 instance identity: manifest authority, startup verification and init' (#415) from feature/recovery-u1-instance-identity into main
|
||||
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
|
||||
|
||||
### task-finish 收口补记(2026-10-12)
|
||||
|
||||
- GATE:PR #415 @ `6d453ee`,ci/test + ci/test-pg 双 success。
|
||||
- 回收:worktree `…-wt-feature-recovery-u1-instance-identity` 已移除;远端 `feature/recovery-u1-instance-identity` 已删。
|
||||
- 剩余项:I3(真实 PG/SQLite 表 `agent_system_identity`)→ 新任务 `feature/recovery-u1-db-identity`。
|
||||
+27
-8
@@ -1,6 +1,6 @@
|
||||
# feature/service-restart-recovery-plan 任务卡
|
||||
|
||||
任务主记录:modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md r2(已批准 2026-10-12);主记录 PR:Dev #403(正文与审阅记录随 Documents #75 合入,顺序 #75→#403);状态:待合入(CI 审核中);owner:weichen
|
||||
任务主记录:PR #403 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/403(merge a1ac948155);主记录 PR:Dev #403(正文与审阅记录随 Documents #75 合入,顺序 #75→#403);状态:已完成(回填);owner:weichen
|
||||
|
||||
## 基线与授权
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
| 需求表确认 | r2(documents 71cd9d1) | 已批准 | weichen | 2026-10-12 | 同上 |
|
||||
| 方案设计 | r2(documents 71cd9d1) | 已批准 | weichen | 2026-10-12 | 同上 |
|
||||
| 实施计划 | r2(documents 71cd9d1) | 已批准 | weichen | 2026-10-12 | 同上 |
|
||||
| 实施与自检 | 待定 | 未评审 | — | — | — |
|
||||
| 正式测试与验收 | 待定 | 未评审 | — | — | — |
|
||||
| 代码 Review | 待定 | 未评审 | — | — | — |
|
||||
| 合入 | 待定 | 未评审 | — | — | — |
|
||||
| 合入核验与收口 | 待定 | 未评审 | — | — | — |
|
||||
| 实施与自检 | 不适用 | 不适用(方案卡) | — | — | 本任务只交付方案;实施由 U1–U7 增量任务承接(落地见「规划进展」) |
|
||||
| 正式测试与验收 | 不适用 | 不适用(方案卡) | — | — | 同上;业务验证在各增量任务的验收中执行 |
|
||||
| 代码 Review | 不适用 | 不适用(方案卡) | — | — | 方案审阅见过程仓 records 两轮(六角色自审+外部源码复核) |
|
||||
| 合入 | documents #75 → Dev #403(merge a1ac948155) | 已合入 | weichen | 2026-10-12 | PR #403 已包含于 origin/main(自动核验) |
|
||||
| 合入核验与收口 | 本闭合分支(card-close 2026-10-12-020646Z) | 已批准 | weichen | 2026-10-12 | 用户 2026-10-12 指示「把遗留项按建议修复」覆盖本卡落地回填与闭合 |
|
||||
|
||||
## 探索试验与迭代
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
## 结果与验证
|
||||
|
||||
- 结果:本地待审恢复方案r2及Dev/过程仓入口(documents/父仓均已本地提交固定,未推送);详细审阅结果见过程仓records同名review记录两轮(六角色自审+外部源码复核)
|
||||
- 验证:SELF启动precheck、task-sync、source audit和文档差异/链接检查见工具原始收据;草稿收据含未提交非任务卡改动,被check-evidence标为unverified,不作正式验收依据;业务/真实PG身份/故障/模型/迁移/部署/运行验收均NOT_RUN
|
||||
@@ -76,8 +76,21 @@
|
||||
|
||||
## 规划进展与下一步
|
||||
|
||||
- 落地进展(2026-10-12 回填,依据 origin/main 实测):
|
||||
- **U2 首切片已合入并实测**:svc-autorecover(Dev PR #396/#400/#401)——autostart 期望态 +
|
||||
`svc.sh recover` + systemd watchdog(`agent-system-svc-recover.timer` 每分钟),kill -9 实测
|
||||
≤60s 自动拉回;dev/genui/rdm1/rdm1web 四服务已登记 autostart=1。
|
||||
- **U1 已合入**:Dev PR #415(manifest 权威、启动校验与 init)。
|
||||
- **U2 残留「agent SQLite 自动备份缺位」已补实用切片**:Dev PR #417(2026-10-12)——
|
||||
`svc.sh <name> backup/backups`,SQLite 一致性导出(.backup+integrity_check,方案 §6 约束),
|
||||
快照根在 runtime/ 之外的 `runtime-backups/<name>/`;真机 dev/genui/rdm1 已完成首备
|
||||
(dev 快照 49 表 integrity ok)。restore/组合备份合同仍属 U6。
|
||||
|
||||
|
||||
- 本次推进/剩余:形成候选r1(六角色自审)与r2(外部源码复核吸收:U3停机预算来源变更、U1程序/cwd验收、U4状态映射表、第9节svc-autorecover归属二选一),补充协作存储R7恢复合同;全部业务实现与运行验证未执行
|
||||
- 首选下一任务:U1设计细化与U3生命周期设计;U2首切片已由svc-autorecover任务合入main(PR #396/#401,CI绿;残留:postgres真实recover未实测、agent SQLite自动备份缺位),U2余量(KTD1/KTD2 manifest合同对齐、服务组/单一监管者)另立增量
|
||||
- 首选下一任务:U1 已由 #415 落地(其收尾与验收见该任务卡);下一步为 U3 生命周期设计与
|
||||
U2 余量(KTD1/KTD2 manifest 合同对齐、服务组/单一监管者、postgres 真实 recover 实测、
|
||||
按批准 RPO 的自动快照频率)——均另立增量,未授权不启动
|
||||
- 推荐依据:先保证DB/data身份不漂移、配置更新完整和真实ready,再接监管与自动任务续跑
|
||||
- 前置条件/待确认:r2已批准、svc-autorecover归属已裁定(2026-10-12选项a:作U2实施载体);仍待固定:目标实例/服务组、数据归属、管理权、故障档和RPO/RTO(对应实施增量启动前)
|
||||
- 预期产物/验收条件:兼容manifest合同、数据归属清单、真实PG隔离恢复回归和生命周期设计;判据见主案U1/U3
|
||||
@@ -433,3 +446,9 @@
|
||||
"receipt_sha256": "923876ca5027520d486d24e92a6c77a92a102d015fbe54c896e56f4ee079d013"
|
||||
}
|
||||
```
|
||||
|
||||
## 闭合记录(card-autoclose 回填)
|
||||
|
||||
- 依据:merge a1ac948155eb8957a87f37d8da4a9feb870dd532(PR #403)已包含于 origin/main,分支成果已落盘;本段为合入后回填。
|
||||
- 合并主题:Merge pull request 'Deliver the approved service restart and recovery plan (documents pin + task card)' (#403) from feature/service-restart-recovery-plan into main
|
||||
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
|
||||
File diff suppressed because one or more lines are too long
+26
@@ -583,6 +583,32 @@ scripts/svc.sh db backups pg1 # 列表(时间 / 大小 / 文
|
||||
- 完成后 `pg_restore --list` 自检归档有效性,失败即删除该文件并记 `backup fail`;
|
||||
- 备份**只增不删**;随 `rm --purge` 一并删除。
|
||||
|
||||
### 8.1.1 agent 数据目录快照(SQLite 一致性备份)
|
||||
|
||||
```bash
|
||||
scripts/svc.sh dev backup # 快照 data/ + svc.env + history.jsonl
|
||||
scripts/svc.sh dev backup --keep 3 # 备份后只保留最近 3 份
|
||||
scripts/svc.sh dev backup --out /mnt/bk/dev1 # 自定义快照目录(不参与自动清理)
|
||||
scripts/svc.sh dev backups # 快照列表(时间 / 大小 / 文件数)
|
||||
scripts/svc.sh dev backups --prune --keep 3 # 手动清理旧快照
|
||||
```
|
||||
|
||||
- 位置:`runtime-backups/<name>/<UTC时间戳>/`——**在 runtime/ 之外**。2026-10-11
|
||||
发生过 `runtime/` 被整体误删的事故(SQLite 聊天/设置数据全失,且无任何备份);
|
||||
放在服务目录内的备份会跟着一起消失,因此快照根独立于 runtime/;
|
||||
- 一致性:每个 `*.db` 经 `sqlite3 .backup` 导出(并入 WAL 内容,服务运行中也可
|
||||
安全备份)并做 `PRAGMA integrity_check` 自检;`-wal`/`-shm` 不入快照。无 sqlite3
|
||||
时**拒绝备份**,不回退裸拷贝(裸拷会漏 WAL 得到撕裂副本);
|
||||
- MANIFEST:快照内 `MANIFEST`(600)记录服务、来源版本与逐文件 sha256,恢复时
|
||||
可校验完整性。非 SQLite 的 `*.db` 文件(无文件头)保留原样副本并告警;
|
||||
- 保留策略:默认保留最近 7 份(`--keep N` 调整;只清理符合时间戳命名的快照目录,
|
||||
不动 `--out` 指定的自定义目录);`rm <name> --purge` 会连带删除
|
||||
`runtime-backups/<name>/`,`rm`(不带 --purge)保留快照;
|
||||
- 边界:本命令是**数据面快照**,不含 restore(恢复到新目标、manifest 合同与
|
||||
外部效果对账属恢复方案 U6,见
|
||||
`modules/documents/docs/proposals/2026-10-12-service-restart-data-recovery.md`);
|
||||
postgres 实例请用 `db backup`,static 服务无数据目录。
|
||||
|
||||
### 8.2 恢复
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
Submodule modules/documents updated: b680142586...bd5fb18519
+186
-7
@@ -7,6 +7,8 @@
|
||||
# scripts/svc.sh <name> [action] [参数...] 服务级:deploy(默认)|start|stop|restart|status|logs [N]
|
||||
# |db [pg名|--clear](agent 数据库绑定/切换)
|
||||
# |config [file|--clear](agent 配置文件更新/生效)
|
||||
# |backup [--out D] [--keep N](agent 数据目录快照)
|
||||
# |backups [--prune](快照列表 / 按保留数清理)
|
||||
# scripts/svc.sh list [--tag T] 服务总览(status 等价)
|
||||
# scripts/svc.sh info [name] [--tag T] [--reveal] 综合报告(只读):服务/数据库/配置快照/最近事件
|
||||
# scripts/svc.sh set <name> [--port N --apply] [--tag T] 变更登记(换端口/标签)
|
||||
@@ -59,6 +61,7 @@
|
||||
# db backup:--db X --out F db restore:--file F --db X --force
|
||||
# db clone:--port P --host H --password PW --tag T --online
|
||||
# db copy:--db X --force set:--port N --host H --apply --tag T
|
||||
# backup:--out DIR --keep N backups:--prune --keep N(agent 数据目录快照)
|
||||
#
|
||||
# ════════════════════ 配置优先级 ════════════════════
|
||||
# 命令行 > --config/--env 文件 > svc.env 持久化(exec=/addr=/dbref=/dsn=/db=)
|
||||
@@ -77,6 +80,9 @@
|
||||
# - runtime/<name>/:bin|htdocs|data|pgdata|plugins|backups + svc.pid +
|
||||
# svc.log|pg.log + svc.env(登记+持久化配置,600)+ history.jsonl(事件流)
|
||||
# + .start.lock(生命周期互斥锁,recover 与 deploy/手动操作防竞态)
|
||||
# - runtime-backups/<name>/<UTC时间戳>/:agent 数据目录快照(svc.env+history+data;
|
||||
# *.db 经 sqlite3 .backup 一致性导出并自检,MANIFEST 记逐文件 sha256。放 runtime/
|
||||
# 之外——runtime 被整体误删时快照仍在,rm --purge 随服务一并删除)
|
||||
# - kind:agent(后端单二进制,内嵌前端)/ static(前端静态服务)/ postgres(数据库);
|
||||
# 无 kind= 的旧登记自动视为 agent;start/restart 使用环境内产物
|
||||
#
|
||||
@@ -89,6 +95,7 @@
|
||||
# scripts/svc.sh ui-qa deploy --kind static --dir ../some/dist
|
||||
# scripts/svc.sh set pg1 --port 8895 --apply # 换端口并重启生效
|
||||
# scripts/svc.sh db backup pg1 && scripts/svc.sh db backups pg1
|
||||
# scripts/svc.sh dev backup && scripts/svc.sh dev backups # agent 数据目录快照 + 列表
|
||||
# scripts/svc.sh db clone pg1 pg2 --port 8896 # 物理克隆一套到新端口
|
||||
# scripts/svc.sh dev db pg1 --db dev # 把 dev 服务绑定到 pg1/dev 库(自动重启)
|
||||
# scripts/svc.sh dev db pg2 --no-restart # 切换但不重启(下次 start 生效)
|
||||
@@ -105,7 +112,7 @@
|
||||
# make svc ENV=dev ARGS="--env .env.dev --port 9000"
|
||||
#
|
||||
# ════════════════════ 安全约束 ════════════════════
|
||||
# - 只写 runtime/<name>/、runtime/.worktrees/ 与独立部署源仓(SVC_SRC_DIR,默认
|
||||
# - 只写 runtime/<name>/、runtime/.worktrees/、runtime-backups/<name>/ 与独立部署源仓(SVC_SRC_DIR,默认
|
||||
# ~/.cache/agent-system-svc/src,可随时删除重建);不改主检出 git 状态、不动他人现场;
|
||||
# --from local 是唯一写主检出构建产物(bin/、node_modules/、dist/,均 gitignore)
|
||||
# 的路径,dev@sha/release/static 构建全部在隔离 worktree(自部署源仓物化);
|
||||
@@ -113,8 +120,10 @@
|
||||
# pg_ctl -m fast 优雅停机,SIGKILL 不用于 postmaster;
|
||||
# - 端口被非本脚本进程占用时报错并给出占用者,不抢不杀;
|
||||
# - svc.env/备份文件权限 600;DSN 默认掩码密码(--reveal 才显示);
|
||||
# - 备份/克隆只增不删;restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复);
|
||||
# 备份文件在 runtime/<name>/backups/,随 rm --purge 一并删除;
|
||||
# - 备份/克隆只增不删(backup --keep / backups --prune 的显式保留策略除外);
|
||||
# restore/copy 覆盖非空库必须显式 --force(drop 重建再恢复);
|
||||
# pg 备份在 runtime/<name>/backups/,agent 快照在 runtime-backups/<name>/(在
|
||||
# runtime/ 之外,防整删事故连带),均随 rm --purge 一并删除;
|
||||
# - 物理克隆期间源实例短暂停止(一致性要求),完成后自动复启;--online 不停源。
|
||||
#
|
||||
# 完整文档:docs/svc.md
|
||||
@@ -159,6 +168,8 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
|
||||
scripts/svc.sh <name> start|stop|restart|logs [N] 生命周期与日志
|
||||
scripts/svc.sh <name> status 服务详情(版本/来源/DSN 等)
|
||||
scripts/svc.sh <name> db [pg名|--clear] agent 数据库绑定/切换(缺省查看)
|
||||
scripts/svc.sh <name> backup [--out D] [--keep N] agent 数据目录快照(SQLite 一致性备份)
|
||||
scripts/svc.sh <name> backups [--prune] 快照列表 / 按保留数清理
|
||||
scripts/svc.sh <name> config [file|--clear] agent 配置文件更新/即时生效(缺省查看)
|
||||
scripts/svc.sh db create pg1 --port 8890 建数据库
|
||||
scripts/svc.sh db dsn|backup|backups|restore|clone|copy|ls|set pg1 ... 数据库运维
|
||||
@@ -217,6 +228,12 @@ update deploy stop start restart logs help info version。
|
||||
history.jsonl 事件流(append-only,一行一个 JSON)
|
||||
.start.lock 生命周期互斥锁(recover 与 deploy/手动 start 防竞态)
|
||||
|
||||
runtime-backups/<name>/<UTC时间戳>/
|
||||
agent 数据目录快照:svc.env + history.jsonl + data/(*.db 经 sqlite3 .backup
|
||||
导出一致性副本并入 WAL 内容,integrity_check 自检;-wal/-shm 不入快照;
|
||||
MANIFEST 记逐文件 sha256)。位于 runtime/ 之外——runtime 被整体误删时快照
|
||||
仍在;rm --purge 随服务一并删除。
|
||||
|
||||
svc.env 字段:
|
||||
kind agent|static|postgres(旧登记无此键视为 agent)
|
||||
env 服务名
|
||||
@@ -245,7 +262,7 @@ history.jsonl 事件(scripts/svc.sh history 查看):
|
||||
recover ok|fail(watchdog/手动 recover 拉回死亡服务)
|
||||
stop ok|fail
|
||||
set 登记变更(addr 旧 → 新)
|
||||
backup ok|fail(文件与大小)
|
||||
backup ok|fail(pg:库/文件/大小;agent:快照目录/大小)
|
||||
restore ok|fail(库、文件、表数)
|
||||
clone ok|fail(物理/在线)
|
||||
copy ok|fail
|
||||
@@ -340,6 +357,10 @@ EOF
|
||||
scripts/svc.sh rm <name> 停止并移除登记(目录/数据/历史保留)
|
||||
scripts/svc.sh rm <name> --purge 连 runtime/<name>/ 整个目录一起删除
|
||||
|
||||
数据目录快照(agent;SQLite 一致性导出,详见 docs/svc.md):
|
||||
scripts/svc.sh <name> backup [--out DIR] [--keep N] 快照到 runtime-backups/<name>/
|
||||
scripts/svc.sh <name> backups [--prune] [--keep N] 列表 / 按保留数清理旧快照
|
||||
|
||||
自动恢复(崩溃 / OOM 被杀 / 机器重启后自动拉回;详见 help recovery):
|
||||
scripts/svc.sh recover [name] [--quiet] 把 autostart=1 但已死的服务重新拉起
|
||||
scripts/svc.sh watchdog install systemd 用户 timer 每分钟执行 recover
|
||||
@@ -669,7 +690,7 @@ addr_compose() {
|
||||
}
|
||||
|
||||
# ---- 名称与端口 ----
|
||||
RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version recover watchdog "
|
||||
RESERVED_ENVS=" status list add set db rm create dsn commits history update deploy stop start restart logs help info version recover watchdog backup backups "
|
||||
|
||||
# valid_env NAME:服务名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用)。
|
||||
valid_env() {
|
||||
@@ -3007,6 +3028,150 @@ EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- agent 数据目录快照(SQLite 一致性备份;2026-10-12)----
|
||||
# 快照根在 runtime-backups/<name>/(runtime/ 之外):2026-10-11 runtime/ 被整体
|
||||
# 误删的事故里,放在 runtime/<name>/backups/ 的任何东西都会一起消失。每个 *.db
|
||||
# 经 sqlite3 .backup 导出一致性副本(并入 WAL 内容)并 integrity_check 自检,
|
||||
# 不裸拷 .db(约束见 modules/documents/docs/proposals/
|
||||
# 2026-10-12-service-restart-data-recovery.md §6);-wal/-shm 不入快照。
|
||||
BACKUP_KEEP_DEFAULT=7
|
||||
BACKUP_TS_GLOB='[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z'
|
||||
|
||||
agent_backup_root() { printf '%s/runtime-backups/%s' "$ROOT" "$ENV_NAME"; }
|
||||
|
||||
agent_backup_guard() { # 公共前置:agent 已登记(backups 列表也走这里)
|
||||
if [ "$SVC_KIND" = postgres ]; then
|
||||
c_err "[$ENV_NAME] postgres 用 db backup(pg_dump);backup 是 agent 数据目录快照"
|
||||
exit 2
|
||||
elif [ "$SVC_KIND" != agent ]; then
|
||||
c_err "[$ENV_NAME] kind=$SVC_KIND 无数据目录,不适用 backup"
|
||||
exit 2
|
||||
fi
|
||||
[ -f "$ENV_DIR/svc.env" ] || { c_err "[$ENV_NAME] 未登记(无 svc.env);先 deploy"; exit 2; }
|
||||
}
|
||||
|
||||
agent_backup_snapshot() { # $1=dest:svc.env + history.jsonl + data/(*.db 一致性化)
|
||||
local dest="$1" rel
|
||||
cp -p "$ENV_DIR/svc.env" "$dest/svc.env" || return 1
|
||||
[ -f "$ENV_DIR/history.jsonl" ] && cp -p "$ENV_DIR/history.jsonl" "$dest/history.jsonl"
|
||||
mkdir -p "$dest/data" || return 1
|
||||
# 先整树拷贝(非 db 内容的原样副本),再对 *.db 用一致性导出覆盖:cp 得到的
|
||||
# 撕裂 db/-wal 副本在下一步被丢弃替换,不影响最终一致性。
|
||||
cp -a "$ENV_DATA/." "$dest/data/" || return 1
|
||||
while IFS= read -r rel; do
|
||||
[ -f "$ENV_DATA/$rel" ] || continue
|
||||
case "$rel" in
|
||||
*"'"*|*'"'*)
|
||||
if head -c 15 "$ENV_DATA/$rel" | grep -qa '^SQLite format 3$'; then
|
||||
c_err "[$ENV_NAME] 路径含引号,无法走 sqlite 一致性导出:$rel"
|
||||
return 1
|
||||
fi
|
||||
continue ;;
|
||||
esac
|
||||
if sqlite3 "$ENV_DATA/$rel" ".backup '$dest/data/$rel.bk'" 2>/dev/null \
|
||||
&& [ "$(sqlite3 "$dest/data/$rel.bk" 'PRAGMA integrity_check;' 2>/dev/null)" = ok ]; then
|
||||
mv -f "$dest/data/$rel.bk" "$dest/data/$rel" || return 1
|
||||
chmod 600 "$dest/data/$rel"
|
||||
rm -f "$dest/data/$rel-wal" "$dest/data/$rel-shm" # .backup 已并入 WAL 内容
|
||||
else
|
||||
rm -f "$dest/data/$rel.bk" # 失败尝试的半成品不留在快照里
|
||||
if head -c 15 "$ENV_DATA/$rel" | grep -qa '^SQLite format 3$'; then
|
||||
c_err "[$ENV_NAME] SQLite 一致性备份/自检失败:$rel"
|
||||
return 1
|
||||
fi
|
||||
c_warn "[$ENV_NAME] $rel 不是 SQLite 库(无文件头),保留原样副本"
|
||||
fi
|
||||
done < <(cd "$dest/data" 2>/dev/null && find . -type f -name '*.db' | sort)
|
||||
return 0
|
||||
}
|
||||
|
||||
agent_backup_manifest() { # $1=dest:快照自描述(600)——逐文件 sha256 供恢复时校验
|
||||
local dest="$1"
|
||||
{
|
||||
printf 'service=%s\nkind=%s\ncreated=%s\nsource=%s\nversion=%s\ndata_dir=%s\n' \
|
||||
"$ENV_NAME" "$SVC_KIND" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
"$(svc_get source)" "$(svc_get version)" "$ENV_DATA"
|
||||
( cd "$dest" && find . -type f ! -name MANIFEST -print0 | sort -z | xargs -0 -r sha256sum )
|
||||
} > "$dest/MANIFEST"
|
||||
chmod 600 "$dest/MANIFEST"
|
||||
}
|
||||
|
||||
agent_backup_prune() { # $1=root $2=keep:只清理符合时间戳命名的快照目录(最新在前保留 keep 份)
|
||||
local root="$1" keep="$2" n=0 d
|
||||
[ -d "$root" ] || return 0
|
||||
while IFS= read -r d; do
|
||||
n=$((n + 1))
|
||||
[ "$n" -le "$keep" ] && continue
|
||||
rm -rf "$root/$d"
|
||||
c_info "[$ENV_NAME] 清理旧快照:$d(保留最近 $keep 份)"
|
||||
done < <(cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB" | sort -r)
|
||||
return 0
|
||||
}
|
||||
|
||||
svc_agent_backup() { # [--out DIR] [--keep N]
|
||||
local out="" keep="$BACKUP_KEEP_DEFAULT"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--out) need_value "$@"; out="$2"; shift 2 ;;
|
||||
--keep) need_value "$@"; keep="$2"; shift 2
|
||||
case "$keep" in ''|*[!0-9]*) c_err "--keep 须为非负整数"; exit 2 ;; esac ;;
|
||||
*) c_err "用法:scripts/svc.sh <name> backup [--out DIR] [--keep N](未知参数:$1)"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
agent_backup_guard
|
||||
[ -d "$ENV_DATA" ] || { c_err "[$ENV_NAME] 数据目录不存在:$ENV_DATA"; exit 2; }
|
||||
command -v sqlite3 >/dev/null 2>&1 \
|
||||
|| { c_err "缺少 sqlite3——一致性备份必需,不回退裸拷贝(会漏 WAL 得到撕裂副本)"; exit 1; }
|
||||
local ts dest size
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
dest="${out:-$(agent_backup_root)/$ts}"
|
||||
case "$dest" in /*) ;; *) dest="$PWD/$dest" ;; esac
|
||||
[ ! -e "$dest" ] || { c_err "备份目标已存在:$dest"; exit 1; }
|
||||
mkdir -p "$dest" || { c_err "无法创建备份目录:$dest"; exit 1; }
|
||||
if ! agent_backup_snapshot "$dest" || ! agent_backup_manifest "$dest"; then
|
||||
rm -rf "$dest"
|
||||
hist_event backup fail "$(read_pid)" "dir=$dest"
|
||||
c_err "[$ENV_NAME] 备份失败,半成品已清理:$dest"
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$dest" 2>/dev/null || true
|
||||
size="$(du -sh "$dest" 2>/dev/null | cut -f1)"
|
||||
hist_event backup ok "$(read_pid)" "dir=$dest size=$size"
|
||||
[ -n "$out" ] || agent_backup_prune "$(agent_backup_root)" "$keep"
|
||||
c_ok "[$ENV_NAME] 数据目录快照完成:$dest($size;svc.env+history+data,db 已一致性自检)"
|
||||
}
|
||||
|
||||
svc_agent_backups() { # [--prune] [--keep N]
|
||||
local prune=0 keep="$BACKUP_KEEP_DEFAULT"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--prune) prune=1; shift ;;
|
||||
--keep) need_value "$@"; keep="$2"; shift 2
|
||||
case "$keep" in ''|*[!0-9]*) c_err "--keep 须为非负整数"; exit 2 ;; esac ;;
|
||||
*) c_err "用法:scripts/svc.sh <name> backups [--prune] [--keep N](未知参数:$1)"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
agent_backup_guard
|
||||
local root n=0
|
||||
root="$(agent_backup_root)"
|
||||
if [ -d "$root" ]; then
|
||||
while IFS= read -r _; do n=$((n + 1)); done \
|
||||
< <(cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB")
|
||||
fi
|
||||
if [ "$n" -eq 0 ]; then
|
||||
c_info "[$ENV_NAME] 暂无快照(root=$root;scripts/svc.sh $ENV_NAME backup)"
|
||||
return 0
|
||||
fi
|
||||
c_info "[$ENV_NAME] 快照列表($root,最新在前)"
|
||||
( cd "$root" && find . -maxdepth 1 -mindepth 1 -type d -name "$BACKUP_TS_GLOB" | sort -r ) \
|
||||
| while IFS= read -r d; do
|
||||
printf ' %s %6s %s\n' "${d#./}" "$(du -sh "$root/$d" | cut -f1)" \
|
||||
"$(find "$root/$d/data" -type f 2>/dev/null | wc -l) 个文件"
|
||||
done
|
||||
if [ "$prune" = 1 ]; then agent_backup_prune "$root" "$keep"; fi
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local name="${1:-}"
|
||||
[ -n "$name" ] || { c_err "用法:scripts/svc.sh set <name> [--port N] [--host H] [--apply] [--tag T...]"; exit 2; }
|
||||
@@ -3145,6 +3310,11 @@ cmd_rm() {
|
||||
rm -f "$ENV_DIR/svc.env" "$ENV_DIR/svc.pid"
|
||||
if [ "$CLI_PURGE" = 1 ]; then
|
||||
rm -rf "${ENV_DIR:?}"
|
||||
local bkroot="$ROOT/runtime-backups/$name"
|
||||
if [ -d "$bkroot" ]; then
|
||||
rm -rf "$bkroot"
|
||||
c_ok "[$ENV_NAME] 数据目录快照已删除:$bkroot"
|
||||
fi
|
||||
c_ok "[$ENV_NAME] 已注销并删除整个目录:$ENV_DIR(含数据与历史)"
|
||||
else
|
||||
c_ok "[$ENV_NAME] 已注销登记;目录保留(数据/历史在 $ENV_DIR;彻底删除用 --purge)"
|
||||
@@ -4021,12 +4191,13 @@ main() {
|
||||
# 动作词可省略(省略 = deploy);flags 可直接跟随服务名。
|
||||
local action="deploy"
|
||||
case "${1:-}" in
|
||||
deploy|stop|start|restart|status|logs|db|config|init|user) action="$1"; shift ;;
|
||||
deploy|stop|start|restart|status|logs|db|config|init|user|backup|backups) action="$1"; shift ;;
|
||||
esac
|
||||
if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||
CLI_LOGS_N="$1"; shift
|
||||
fi
|
||||
# db / config / user 子命令:自带参数解析(位置参数不走 parse_args)。
|
||||
# db / config / user / backup / backups:自带参数解析(位置参数不走 parse_args;
|
||||
# 未知 flags 由其自行拒绝)。
|
||||
if [ "$action" = db ] || [ "$action" = config ] || [ "$action" = user ]; then
|
||||
env_config "$ENV_NAME"
|
||||
case "$action" in
|
||||
@@ -4036,6 +4207,14 @@ main() {
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
if [ "$action" = backup ] || [ "$action" = backups ]; then
|
||||
env_config "$ENV_NAME"
|
||||
case "$action" in
|
||||
backup) svc_agent_backup "$@" || exit $? ;;
|
||||
backups) svc_agent_backups "$@" || exit $? ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
parse_args "$@"
|
||||
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ]; } && [ "$action" != deploy ]; then
|
||||
c_err "--from/--pick/--dry-run/--dir 仅用于 deploy 动作"
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""svc.sh agent 数据目录快照回归(2026-10-12)。
|
||||
|
||||
验证 backup/backups:活跃 WAL 写入者下 SQLite 一致性导出(.backup + integrity_check,
|
||||
不裸拷 .db/-wal)、快照落在 runtime/ 之外的 runtime-backups/<name>/、MANIFEST 逐文件
|
||||
sha256、--keep/--prune 保留策略只清理时间戳目录、--out 自定义目录不参与清理、
|
||||
kind 守卫(postgres 引导 db backup)、缺 sqlite3 拒绝备份、rm --purge 连带删除快照。
|
||||
全部使用临时目录 + 手工登记 svc.env(backup 不触碰 bin/进程,无需桩二进制)。
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
|
||||
|
||||
SQLITE3 = shutil.which("sqlite3")
|
||||
|
||||
# 活跃写入者:后台进程持有 WAL 连接(提交后不关闭),保证备份时 -wal 真实存在——
|
||||
# 裸拷 .db 会丢这部分已提交内容,.backup 不会。
|
||||
WRITER = r"""
|
||||
import sqlite3, sys, time
|
||||
db = sys.argv[1]
|
||||
c = sqlite3.connect(db)
|
||||
c.execute("pragma journal_mode=wal")
|
||||
c.execute("create table if not exists t(x)")
|
||||
c.execute("insert into t values (42)")
|
||||
c.commit()
|
||||
print("ready", flush=True)
|
||||
time.sleep(float(sys.argv[2]))
|
||||
"""
|
||||
|
||||
|
||||
@unittest.skipUnless(SQLITE3, "agent 备份依赖 sqlite3 CLI(一致性导出不回退裸拷贝)")
|
||||
class SvcAgentBackupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.mkdtemp(prefix="svc-backup-")
|
||||
self.addCleanup(shutil.rmtree, tmp, True)
|
||||
self.root = Path(tmp)
|
||||
(self.root / "scripts").mkdir(parents=True)
|
||||
shutil.copy2(SVC_SCRIPT, self.root / "scripts" / "svc.sh")
|
||||
self.writer_proc = None
|
||||
self.env = os.environ.copy()
|
||||
self.env.update({
|
||||
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"SVC_SRC_DIR": str(self.root / "srccache"),
|
||||
"XDG_CONFIG_HOME": str(self.root / "xdg"),
|
||||
})
|
||||
for key in list(self.env):
|
||||
if key.startswith("SVC_") and key != "SVC_SRC_DIR":
|
||||
self.env.pop(key)
|
||||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
|
||||
self.env.pop(key, None)
|
||||
|
||||
# ---- 夹具 ----
|
||||
|
||||
def svc(self, *args, check=True, env=None):
|
||||
proc = subprocess.run(
|
||||
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
|
||||
cwd=self.root, env=env if env is not None else self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
|
||||
if check:
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout)
|
||||
return proc
|
||||
|
||||
def register_agent(self, name="t1", port=8899):
|
||||
d = self.root / "runtime" / name
|
||||
(d / "data").mkdir(parents=True, exist_ok=True)
|
||||
(d / "svc.env").write_text(
|
||||
"kind=agent\nenv=%s\npid=\naddr=127.0.0.1:%d\ntags=\n"
|
||||
"source=dev@test123\nversion=stub\nstarted=\npidcheck=\n"
|
||||
"exec=--addr 127.0.0.1:%d --data %s\n" % (name, port, port, d / "data"))
|
||||
return d
|
||||
|
||||
def seed_db(self, name="t1", live_writer=True, hold_seconds=30):
|
||||
"""建 WAL 库并提交一行;live_writer=True 时留后台连接持有 WAL。"""
|
||||
db = self.root / "runtime" / name / "data" / "agent-system.db"
|
||||
if live_writer:
|
||||
self.writer_proc = subprocess.Popen(
|
||||
["python3", "-c", WRITER, str(db), str(hold_seconds)],
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
self.assertEqual(self.writer_proc.stdout.readline().strip(), "ready")
|
||||
self.addCleanup(self.stop_writer)
|
||||
else:
|
||||
c = sqlite3.connect(db)
|
||||
c.execute("pragma journal_mode=wal")
|
||||
c.execute("create table t(x)")
|
||||
c.execute("insert into t values (42)")
|
||||
c.commit()
|
||||
c.close()
|
||||
return db
|
||||
|
||||
def stop_writer(self):
|
||||
if self.writer_proc and self.writer_proc.poll() is None:
|
||||
self.writer_proc.kill()
|
||||
self.writer_proc.wait(timeout=10)
|
||||
|
||||
def snapshots(self, name="t1"):
|
||||
root = self.root / "runtime-backups" / name
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return sorted(p.name for p in root.iterdir() if p.is_dir())
|
||||
|
||||
# ---- 用例 ----
|
||||
|
||||
def test_backup_live_writer_consistent_and_outside_runtime(self):
|
||||
"""活跃 WAL 写入者:快照可读、行在、integrity ok;-wal/-shm 不入快照。"""
|
||||
self.register_agent()
|
||||
db = self.seed_db()
|
||||
self.assertTrue(db.with_name("agent-system.db-wal").exists()) # 前置:WAL 真实存在
|
||||
proc = self.svc("t1", "backup")
|
||||
self.assertIn("runtime-backups/t1/", proc.stdout)
|
||||
snaps = self.snapshots()
|
||||
self.assertEqual(len(snaps), 1)
|
||||
snap = self.root / "runtime-backups" / "t1" / snaps[0]
|
||||
# 位置在 runtime/ 之外(runtime 整删不波及)
|
||||
self.assertTrue(snap.exists())
|
||||
self.assertFalse((self.root / "runtime" / "t1" / "backups").exists())
|
||||
# 内容:登记 + 数据库一致性副本
|
||||
self.assertTrue((snap / "svc.env").exists())
|
||||
bak = snap / "data" / "agent-system.db"
|
||||
self.assertTrue(bak.exists())
|
||||
self.assertFalse((snap / "data" / "agent-system.db-wal").exists())
|
||||
self.assertFalse((snap / "data" / "agent-system.db-shm").exists())
|
||||
out = subprocess.run([SQLITE3, str(bak), "select x from t; pragma integrity_check;"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(out.stdout.split(), ["42", "ok"], out.stderr)
|
||||
|
||||
def test_backup_manifest_sha_matches(self):
|
||||
self.register_agent()
|
||||
self.seed_db(live_writer=False)
|
||||
self.svc("t1", "backup")
|
||||
snap = self.root / "runtime-backups" / "t1" / self.snapshots()[0]
|
||||
manifest = (snap / "MANIFEST").read_text()
|
||||
self.assertIn("service=t1\n", manifest)
|
||||
self.assertIn("source=dev@test123\n", manifest)
|
||||
entries = [l.split() for l in manifest.splitlines() if " ./" in l]
|
||||
self.assertTrue(entries)
|
||||
for sha, rel in entries:
|
||||
self.assertRegex(sha, r"^[0-9a-f]{64}$")
|
||||
self.assertEqual(
|
||||
subprocess.run(["sha256sum", str(snap / rel)],
|
||||
capture_output=True, text=True).stdout.split()[0], sha)
|
||||
|
||||
def test_backup_non_sqlite_db_kept_raw_without_leftovers(self):
|
||||
self.register_agent()
|
||||
self.seed_db(live_writer=False)
|
||||
junk = self.root / "runtime" / "t1" / "data" / "blob.db"
|
||||
junk.write_bytes(b"plain-bytes-not-a-sqlite-file")
|
||||
proc = self.svc("t1", "backup")
|
||||
self.assertIn("blob.db", proc.stdout) # 告警原样保留
|
||||
snap = self.root / "runtime-backups" / "t1" / self.snapshots()[0]
|
||||
self.assertEqual((snap / "data" / "blob.db").read_bytes(), b"plain-bytes-not-a-sqlite-file")
|
||||
self.assertFalse(list(snap.rglob("*.bk"))) # 失败尝试的半成品不残留
|
||||
|
||||
def test_backup_target_exists_refused(self):
|
||||
self.register_agent()
|
||||
self.seed_db(live_writer=False)
|
||||
dest = self.root / "runtime-backups" / "t1" / "20260101T000000Z"
|
||||
dest.mkdir(parents=True)
|
||||
(dest / "marker").write_text("x")
|
||||
proc = self.svc("t1", "backup", "--out", dest, check=False)
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout)
|
||||
self.assertIn("已存在", proc.stdout)
|
||||
self.assertTrue((dest / "marker").exists()) # 不覆盖既有内容
|
||||
|
||||
def test_backup_custom_out_not_pruned(self):
|
||||
self.register_agent()
|
||||
self.seed_db(live_writer=False)
|
||||
outdir = self.root / "custom-bk"
|
||||
self.svc("t1", "backup", "--out", outdir)
|
||||
self.assertTrue((outdir / "data" / "agent-system.db").exists())
|
||||
# 默认 root 无快照;再走默认路径备份 --keep 0 也不应动自定义目录
|
||||
self.svc("t1", "backup", "--keep", "0")
|
||||
self.assertEqual(self.snapshots(), [])
|
||||
self.assertTrue(outdir.exists())
|
||||
|
||||
def test_retention_and_listing(self):
|
||||
self.register_agent()
|
||||
self.seed_db(live_writer=False)
|
||||
for _ in range(3):
|
||||
self.svc("t1", "backup")
|
||||
time.sleep(1.1) # 时间戳目录名唯一(UTC 秒级)
|
||||
self.assertEqual(len(self.snapshots()), 3)
|
||||
proc = self.svc("t1", "backups")
|
||||
for name in self.snapshots():
|
||||
self.assertIn(name, proc.stdout)
|
||||
# backup --keep 2 → 剩 2 份最新
|
||||
self.svc("t1", "backup", "--keep", "2")
|
||||
self.assertEqual(len(self.snapshots()), 2)
|
||||
# backups --prune --keep 1 → 剩最新 1 份
|
||||
self.svc("t1", "backups", "--prune", "--keep", "1")
|
||||
self.assertEqual(len(self.snapshots()), 1)
|
||||
|
||||
def test_backup_event_recorded(self):
|
||||
self.register_agent()
|
||||
self.seed_db(live_writer=False)
|
||||
self.svc("t1", "backup")
|
||||
hist = (self.root / "runtime" / "t1" / "history.jsonl").read_text()
|
||||
self.assertIn('"event":"backup"', hist)
|
||||
self.assertIn('"result":"ok"', hist)
|
||||
self.assertIn("runtime-backups/t1/", hist)
|
||||
|
||||
def test_backup_rejects_postgres_with_db_hint(self):
|
||||
d = self.root / "runtime" / "pgx"
|
||||
d.mkdir(parents=True)
|
||||
(d / "svc.env").write_text("kind=postgres\nenv=pgx\naddr=127.0.0.1:8890\n")
|
||||
proc = self.svc("pgx", "backup", check=False)
|
||||
self.assertEqual(proc.returncode, 2, proc.stdout)
|
||||
self.assertIn("db backup", proc.stdout)
|
||||
|
||||
def test_backup_requires_registration(self):
|
||||
proc = self.svc("ghost", "backup", check=False)
|
||||
self.assertEqual(proc.returncode, 2, proc.stdout)
|
||||
|
||||
def test_backup_without_sqlite3_refuses(self):
|
||||
"""PATH 无 sqlite3 → 拒绝(不回退裸拷贝)。重建与真实 PATH 等价的工具目录、
|
||||
唯独不链 sqlite3——只测 command -v 失败这一条分支,其余行为不变形。"""
|
||||
self.register_agent()
|
||||
self.seed_db(live_writer=False)
|
||||
bindir = self.root / "bin-nosqlite3"
|
||||
bindir.mkdir()
|
||||
seen = set()
|
||||
for d in self.env.get("PATH", os.environ["PATH"]).split(os.pathsep):
|
||||
if not d or not os.path.isdir(d):
|
||||
continue
|
||||
for f in os.listdir(d):
|
||||
if f == "sqlite3" or f in seen or (bindir / f).exists():
|
||||
continue
|
||||
seen.add(f)
|
||||
try:
|
||||
os.symlink(os.path.join(d, f), bindir / f)
|
||||
except OSError:
|
||||
pass
|
||||
self.assertFalse((bindir / "sqlite3").exists())
|
||||
env = dict(self.env)
|
||||
env["PATH"] = str(bindir)
|
||||
proc = self.svc("t1", "backup", check=False, env=env)
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout)
|
||||
self.assertIn("sqlite3", proc.stdout)
|
||||
self.assertEqual(self.snapshots(), []) # 无半成品快照
|
||||
|
||||
def test_rm_purge_removes_snapshots(self):
|
||||
self.register_agent()
|
||||
self.seed_db(live_writer=False)
|
||||
self.svc("t1", "backup")
|
||||
self.assertEqual(len(self.snapshots()), 1)
|
||||
self.svc("rm", "t1", "--purge")
|
||||
self.assertFalse((self.root / "runtime-backups" / "t1").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user