Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions doc/architecture/SPEC/kb_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,23 @@ stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_serv
所有页面落定后基线才推进;兜底巡检只做 T1 与 purge。
- 熔断暂停与状态变化在同一加锁事务中发布控制记录与暂停清单。


## 2026-09-28 发布器
`publisher` 在 GPU 盒上以仓库负责人的 gh 登录运行(`kb publish`),是知识 PR 唯一的 GitHub 写入方。
- 每轮:经 SSH(或本地目录)读取签名控制记录,过期即整轮不动;对每个 outbox 项执行 `check_item`(签名、
过期、代际、暂停、控制记录与**发布器自己的适配器配置**同时为 `auto_merge`),执行后先在本地记录结果,
再写回发布器密钥签名的 `kb-ack`(字段:item_id、kind、changeset_id、ok、pr、head_sha、branch、error)。
- 幂等:已执行的项只重发 ack(服务收取 ack 后删除该项);完成记录原子写入,损坏的记录移到 `.corrupt` 并等人处理,
绝不猜测是否已执行;`open_pr` 只复用 head 恰为重建提交的已打开 PR(companion 还须是 draft);
提交由签名内容在 `base_sha` 上用临时索引重建,日期取自该项,重试得到同一 SHA;分支已存在且内容不同则拒绝,
绝不覆盖。路径必须是受治理的知识页面(`knowledge/{repos/<r>,general}/**.md|yaml`,无 `..`)。
- 恢复:`post_verdict` 只在未暂停、当前代际下签发,发布它时先移除 `kb:hold` 标签并确认已移除,再发布评论,使评论触发的预检不再因暂停标签失败。
失败的 `pause`/`close` 不回执,留在 outbox 中每轮重试直到成功;格式错误的控制记录或项目记入 trace 并跳过。
SSH 读取或回执失败只记入 trace,不终止进程;完成记录保留,下一轮重发回执而不重复动作。
- 动作:`open_pr`/`open_companion_pr`(始终 draft)、`post_verdict` 与 `enqueue`/`update_branch`(先核对 PR
仍打开且 head 未变;`enqueue` 先把 draft 转为 ready,再 `gh pr merge --auto --match-head-commit`)、
`pause`(程序 P:出队 → 关闭自动合并 → 转 draft,确认已是 draft,再加 `kb:hold`)、`close`。
- 双重门控:无 `ALLOW_POST=1` 只记录将要执行的动作;推送分支还需 `ALLOW_PUSH=1`。无法验证或不可执行的项
既不执行也不回执。每轮与每个决定都写入 `traces/publisher.jsonl`。
- 测试:`test_kb_publisher.py`(端到端 open_pr → ack → pr_open、dry-run、崩溃后不重复执行、伪造/未配置/过期、
越界路径、verdict/入队/暂停/关闭、暂停期间仍执行暂停、SSH 引号与回执、ack 字段)。
17 changes: 17 additions & 0 deletions doc/features/knowledge-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,20 @@ python tools/build_kb_gate_bundle.py --lock # 从 PyPI 重新按哈希固定
在 `.github/CODEOWNERS` 中再加入至少一名知识维护者;仓库 admin 开启合并队列(merge commit)
与 ruleset(必需检查 `kb-gate` 限定 GitHub Actions 来源、Code Owner 审阅、新推送需重新批准)。
在此之前,所有触碰 `knowledge/` 的 PR 都会被 `kb-gate` 拒绝(失败即关闭),代码 PR 不受影响。

## 发布器(GPU 盒)

发布器以仓库负责人现有的 `gh` 登录执行服务签名的 outbox 项,写回签名回执。凭据不离开 GPU 盒。

```bash
infermatrix-copilot kb keygen --out ~/.infermatrix-copilot/kb-publisher.pem # 发布器密钥;公钥给服务的 KB_PUBLISHER_PUBKEY
export KB_SERVICE_PUBKEY=/path/to/service.pub # 服务公钥(kb keygen 输出的一行)
export KB_PUBLISHER_KEY=~/.infermatrix-copilot/kb-publisher.pem
export KB_PUBLISHER_GIT_AUTHOR='Name <email>' # 知识 PR 提交的作者
export KB_PUBLISHER_STATE=/data/<owner>/kb-publisher # 必须在负责人目录下(gh 包装器按目录选择登录)
infermatrix-copilot kb publish --remote bot-host:/path/to/kb-state --once # 只记录(dry-run)
ALLOW_POST=1 ALLOW_PUSH=1 infermatrix-copilot kb publish --remote bot-host:/path/to/kb-state # 常驻
```

没有 `ALLOW_POST=1` 时只把将要执行的动作写入 `$KB_PUBLISHER_STATE/traces/publisher.jsonl`;推送分支还需要
`ALLOW_PUSH=1`。控制记录超过 10 分钟未更新(服务可能宕机)时整轮不执行任何动作。
51 changes: 51 additions & 0 deletions src/infermatrix_copilot/kb_service/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
kb serve [--once] the scheduler (holds the single-writer lease)
kb activate activate the knowledge snapshot of the repository's main now
kb rollback --to SHA point `active` back at an earlier snapshot
kb publish (--remote HOST:/STATE_DIR | --local DIR) [--once]
the publisher (GPU box, owner's gh login): perform
the signed outbox items; writes need ALLOW_POST=1
(and ALLOW_PUSH=1 to push branches)

Pause/resume bump the repository's generation, so every outbox item issued
before it is void; they then re-sign the control record and hold list at once.
Expand All @@ -20,6 +24,7 @@
import argparse
import json
import os
import re
import sys
import time
from pathlib import Path
Expand Down Expand Up @@ -105,6 +110,14 @@ def main(argv: list[str] | None = None) -> int:
sub.add_parser("activate")
rollback = sub.add_parser("rollback")
rollback.add_argument("--to", required=True)
publish = sub.add_parser("publish")
where = publish.add_mutually_exclusive_group(required=True)
where.add_argument("--remote", help="host:/absolute/path of the service state directory (over ssh)")
where.add_argument("--local", help="the service state directory on this machine")
publish.add_argument("--once", action="store_true")
publish.add_argument("--interval", type=float, default=60.0)
publish.add_argument("--publisher-state",
help="default: $KB_PUBLISHER_STATE or ~/.infermatrix-copilot/kb-publisher")
args = parser.parse_args(argv)

if args.command == "keygen":
Expand All @@ -114,6 +127,9 @@ def main(argv: list[str] | None = None) -> int:
print(public_key_text(key.public_key()))
return 0

if args.command == "publish":
return _publish(args)

state_dir = _state_dir(args.state_dir)
ledger = _ledger(state_dir)
try:
Expand Down Expand Up @@ -220,3 +236,38 @@ def _resume():
finally:
ledger.close()
return 2


def _publish(args) -> int:
"""The publisher never opens the service ledger: it only sees the outbox."""
from ..knowledge_service.signing import load_private_key, load_public_key
from .merge import knowledge_repository
from .publisher import Gh, LocalTransport, Publisher, SshTransport

for name in ("KB_SERVICE_PUBKEY", "KB_PUBLISHER_KEY", "KB_PUBLISHER_GIT_AUTHOR"):
if not os.environ.get(name):
raise SystemExit(f"{name} is not set")
author = re.fullmatch(r"\s*(.+?)\s*<([^<>\s]+@[^<>\s]+)>\s*", os.environ["KB_PUBLISHER_GIT_AUTHOR"])
if not author:
raise SystemExit("KB_PUBLISHER_GIT_AUTHOR must look like 'Name <email>'")
state = Path(args.publisher_state or os.environ.get("KB_PUBLISHER_STATE")
or Path.home() / ".infermatrix-copilot" / "kb-publisher").expanduser()
state.mkdir(parents=True, exist_ok=True)
registry = _registry()
publisher = Publisher(
transport=SshTransport.parse(args.remote) if args.remote else LocalTransport(Path(args.local)),
service_public_key=load_public_key(Path(os.environ["KB_SERVICE_PUBKEY"]).read_text(encoding="utf-8")),
publisher_key=load_private_key(os.environ["KB_PUBLISHER_KEY"]),
github=Gh(knowledge_repository(), cwd=state),
state_dir=state,
author=(author.group(1), author.group(2)),
repo_flags={name: (lc.publishes, lc.auto_merge) for name, lc in registry.items() if lc.enabled},
allow_post=os.environ.get("ALLOW_POST") == "1",
allow_push=os.environ.get("ALLOW_PUSH") == "1",
)
if args.once:
json.dump(publisher.run_once(), sys.stdout, sort_keys=True)
print()
return 0
publisher.serve(interval=args.interval)
return 0
Loading
Loading