fix(libraryctl): 封存窗口接上实例级入口闸门与后台静默
build / build (push) Failing after 1m2s

库级窗口永远拿不到在途排空证据:被代理的 WeKnora 收到 SIGTERM 就 exit(1),
写者退出码不能当作请求已排空。capture/recover 改由 librarymaintenance.Manager
装配入口闸门、后台静默检查、目标指纹与入口探针(与发布链路同一套实现),
排空证据来自闸门 drain 记录;缺 LIBRARY_TARGET_* 时在开窗口前拒绝并点名。

维护接线解析收敛到 internal/librarymaintenance/env.go,agent 与 worker 讲同一组
变量名;agent 启动如实打印接线状态;安装脚本强制 0600 agent.env。

试过:make verify 15 项全绿;新增 7 个用例(接线解析/身份绑定/缺闸门拒绝)。
This commit is contained in:
姚章浩
2026-10-29 05:48:38 +08:00
parent ee7688f24e
commit 11c52ad81b
8 changed files with 426 additions and 12 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## 开发快照(封存入口接线:库级窗口接上实例级闸门)
### Fixed
- **主机侧封存不再用"库级窗口"跑实例**:`libraryctl capture` / `recover` 改由
`librarymaintenance.Manager` 装配——入口闸门、后台静默检查、目标指纹、入口探针与发布链路
是同一套实现。实例级维护里"写入者 exit=1"不再等于"在途请求被截断":被代理的 WeKnora 收到
SIGTERM 就 `exit(1)` 是已知上游行为,排空证据来自闸门(`active_at_close` / `drained`),
校验没有放松,而是补上了缺的那一层证据。
- 缺少 `LIBRARY_TARGET_INGRESS_*` / `LIBRARY_TARGET_REDIS_*` 时 `capture` 在**开窗口前拒绝**
并点名缺哪个变量;不再退回一个没有排空证据的窗口(那样的"成功"无法复核)。
- 维护接线解析收敛到 `internal/librarymaintenance/env.go`:agent 与 worker 讲同一组变量名,
空白值算缺失、队列库号必须是非负整数、口令不 Trim(前后空格也是口令)。
- `libraryagent` 启动时如实打印维护目标是否接好(缺了就直说"封存会拒绝")。
- `scripts/library-agent-install.sh` 强制 `agent.env`(0600,`EnvironmentFile=`),并把源容器/
项目/写入者声明做成可覆盖变量;没有 env 文件直接 `AGENT_ENV_MISSING` 退出。
### Tests
- `internal/librarymaintenance/env_test.go`:整份声明解析、逐项点名缺失、空白拒绝、
口令空格保留、非法队列库号。
- `cmd/libraryctl/maintenance_test.go`:入口/队列/源身份绑到同一份配置、数据库身份不泄漏口令、
写入者声明透传、缺闸门拒绝。
## 开发快照(Wiki 撤销贡献:逐页语义比对 + 维护窗口真实呈现)
### Added
+9
View File
@@ -14,6 +14,7 @@ import (
"time"
"knowledge-governance/internal/libraryagent"
"knowledge-governance/internal/librarymaintenance"
)
func main() {
@@ -39,6 +40,14 @@ func main() {
if err != nil {
log.Fatal(err)
}
// 实例级维护接线(入口闸门 + 后台队列)不在 agent 的 Config 里:它由同一份
// 环境声明提供给子进程。启动时先说清楚有没有——缺了封存会在开窗口前被拒,
// 不允许"以为接好了"跑到一半才发现没有排空证据。
if target, targetErr := librarymaintenance.TargetFromEnv(os.Getenv); targetErr != nil {
log.Printf("library agent: 封存会拒绝(实例级维护未接线):%v", targetErr)
} else {
log.Printf("library agent: 实例级维护目标 %s 入口 %s 闸门 %s", target.Instance, target.IngressURL, target.IngressService)
}
if *describe {
log.Printf("library agent config: %+v", agent.Describe())
return
+93 -10
View File
@@ -33,6 +33,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/libraryfreeze"
"knowledge-governance/internal/librarymaintenance"
"knowledge-governance/internal/librarysnapshot"
v071 "knowledge-governance/internal/releaseadapter/v071"
releaseworkflow "knowledge-governance/internal/workflows/release"
@@ -451,6 +452,78 @@ func openFreeze(ctx context.Context, opts options) (*libraryfreeze.Coordinator,
return coordinator, control, nil
}
// captureMaintenanceConfig 把"这次封存动的是哪个实例"落成实例级维护目标。
//
// 为什么必须实例级:被代理的 WeKnora 收到 SIGTERM 会直接 exit(1),写者的退出码
// 因此永远不能当作"在途请求已排空"。排空证据只能在真正的入口上取——关掉闸门、
// 等它把在途连接放完。没有闸门就没有证据,那种封存不允许以"库级窗口"的名义跑
// (真跑过:被 exit=1 拒掉,这是校验在起作用,不是要放松的地方)。
//
// 入口/队列的声明与容器内 worker 共用同一组 LIBRARY_TARGET_* 名称,
// 数据库/索引/桶则取自本次真实发现的源,避免"关的是 A 的门、读的是 B 的队列"。
func captureMaintenanceConfig(opts options, source sourceConfig, get func(string) string) (librarymaintenance.Config, error) {
target, err := librarymaintenance.TargetFromEnv(get)
if err != nil {
return librarymaintenance.Config{}, err
}
writers, nonWriters, err := writerDeclaration(opts)
if err != nil {
return librarymaintenance.Config{}, err
}
identity, err := librarymaintenance.DatabaseIdentity(source.DatabaseURL)
if err != nil {
return librarymaintenance.Config{}, err
}
return librarymaintenance.Config{
Instance: target.Instance,
Project: opts.project,
Writers: writers,
NonWriters: nonWriters,
DockerSocket: opts.dockerSocket,
IngressURL: target.IngressURL,
IngressService: target.IngressService,
IngressDrainFile: target.IngressDrainFile,
Database: identity,
Index: source.SearchIndex,
Bucket: source.Object.Bucket,
// 后台静默检查连的是"正在被封的这个源":未结清的后台账本与队列都在它身上。
DatabaseURL: source.DatabaseURL,
RedisAddr: target.RedisAddr,
RedisPassword: target.RedisPassword,
RedisDB: target.RedisDB,
}, nil
}
// openMaintenance 用实例级目标装配协调器:入口闸门、后台静默检查、目标指纹、
// 入口探针全部来自上面那份声明,与发布链路用的是同一套实现(不是第二份协议)。
func openMaintenance(ctx context.Context, opts options, source sourceConfig) (*libraryfreeze.Coordinator, *libraryfreeze.ProjectController, *librarymaintenance.Manager, error) {
config, err := captureMaintenanceConfig(opts, source, os.Getenv)
if err != nil {
return nil, nil, nil, err
}
manager, err := librarymaintenance.New(config)
if err != nil {
return nil, nil, nil, err
}
// 拓扑先过关:写入者发布了端口 = 存在绕闸直连,这样的"维护"只关了半扇门。
if err = manager.VerifyTopology(ctx); err != nil {
manager.Close()
return nil, nil, nil, fmt.Errorf("目标拓扑不满足唯一入口约束,拒绝开维护窗口:%w", err)
}
coordinator, control, err := manager.Open(opts.journalRoot)
if err != nil {
manager.Close()
return nil, nil, nil, err
}
return coordinator, control, manager, nil
}
// discoverMaintenanceSource 只读"这个源是什么",不碰治理库的 scope 绑定:
// 恢复/收尾不该因为治理库读不到就被卡住。
func discoverMaintenanceSource(ctx context.Context, opts options) (sourceConfig, error) {
return discoverSource(ctx, opts, map[string]string{})
}
func openArchive(opts options) (*librarysnapshot.Archive, error) {
if err := os.MkdirAll(opts.archiveRoot, 0700); err != nil {
return nil, err
@@ -643,10 +716,21 @@ func runCapture(ctx context.Context, opts options) error {
return err
}
defer archive.Close()
coordinator, control, err := openFreeze(ctx, opts)
binding, err := scopeBindings(ctx, opts.governancePG)
if err != nil {
return err
}
config, err := discoverSource(ctx, opts, binding)
if err != nil {
return err
}
// 维护窗口必须先按"实例级"接好:入口闸门给出在途排空证据,后台静默检查
// 给出停写者那一刻队列已空。缺任何一项都拒绝封存——没有证据的封存不是候选。
coordinator, control, manager, err := openMaintenance(ctx, opts, config)
if err != nil {
return err
}
defer manager.Close()
defer coordinator.Close()
defer control.Close()
if err = coordinator.Recover(ctx); err != nil {
@@ -660,10 +744,6 @@ func runCapture(ctx context.Context, opts options) error {
if err != nil {
return err
}
binding, err := scopeBindings(ctx, opts.governancePG)
if err != nil {
return err
}
scopedKB := binding[opts.scope]
if found {
manifest, err := replayCapture(job, opts.scope, scopedKB, existing, archive)
@@ -672,10 +752,6 @@ func runCapture(ctx context.Context, opts options) error {
}
return printCapture(job, existing.SnapshotID, manifest, true)
}
config, err := discoverSource(ctx, opts, binding)
if err != nil {
return err
}
pool, err := openSourceDatabase(ctx, config)
if err != nil {
return err
@@ -957,10 +1033,17 @@ func runVerify(opts options) error {
}
func runRecover(ctx context.Context, opts options) error {
coordinator, control, err := openFreeze(ctx, opts)
// 收尾也必须走同一份实例级目标:崩溃留下的窗口是"入口已关、写入者已停",
// 恢复要用闸门把入口真的重新打开,没有闸门就没有"目标已恢复"的证据。
source, err := discoverMaintenanceSource(ctx, opts)
if err != nil {
return err
}
coordinator, control, manager, err := openMaintenance(ctx, opts, source)
if err != nil {
return err
}
defer manager.Close()
defer coordinator.Close()
defer control.Close()
if err = coordinator.Recover(ctx); err != nil {
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"strings"
"testing"
v071 "knowledge-governance/internal/releaseadapter/v071"
)
func maintenanceEnv() map[string]string {
return map[string]string{
"LIBRARY_TARGET_INSTANCE": "kgmaint-weknora",
"LIBRARY_TARGET_INGRESS_URL": "http://127.0.0.1:18080/health",
"LIBRARY_TARGET_INGRESS_SERVICE": "ingress",
"LIBRARY_TARGET_INGRESS_DRAIN_FILE": "/tmp/gate/drain.json",
"LIBRARY_TARGET_REDIS_ADDR": "127.0.0.1:26379",
"LIBRARY_TARGET_REDIS_PASSWORD": "secret",
"LIBRARY_TARGET_REDIS_DB": "0",
}
}
func captureSource() sourceConfig {
return sourceConfig{
DatabaseURL: "postgres://kgmaint:pw@127.0.0.1:25432/kgmaint_target?sslmode=disable",
SearchIndex: "kgmaint_1028a_chunks",
Object: v071.S3ObjectStoreConfig{Provider: "minio", Bucket: "kgmaint-1028a"},
}
}
func captureOptions() options {
return options{project: "kgmaint-weknora", writers: "app", nonWriters: "postgres=database store",
dockerSocket: "/var/run/docker.sock", journalRoot: "/tmp/kgmaint-journal"}
}
// 封存窗口必须是实例级:入口/队列来自与 worker 同一份 LIBRARY_TARGET_*,
// 库/索引/桶来自本次真实发现的源——两边混用就会"关 A 的门、读 B 的队列"。
func TestCaptureMaintenanceConfigBindsGateQueueAndSourceIdentity(t *testing.T) {
env := maintenanceEnv()
config, err := captureMaintenanceConfig(captureOptions(), captureSource(), func(name string) string { return env[name] })
if err != nil {
t.Fatalf("完整声明被拒:%v", err)
}
if config.Instance != "kgmaint-weknora" || config.Project != "kgmaint-weknora" {
t.Fatalf("实例/项目不对:%+v", config)
}
if config.IngressURL != "http://127.0.0.1:18080/health" || config.IngressService != "ingress" ||
config.IngressDrainFile != "/tmp/gate/drain.json" {
t.Fatalf("入口闸门取值不对:%+v", config)
}
if config.RedisAddr != "127.0.0.1:26379" || config.RedisPassword != "secret" || config.RedisDB != 0 {
t.Fatalf("队列取值不对:%+v", config)
}
// 口令只进结构体,不落进任何可打印身份:身份里只有 host:port/db。
if config.Database != "127.0.0.1:25432/kgmaint_target" {
t.Fatalf("数据库身份不对:%q", config.Database)
}
if strings.Contains(config.Database, "pw") {
t.Fatalf("数据库身份泄漏了口令:%q", config.Database)
}
if config.Index != "kgmaint_1028a_chunks" || config.Bucket != "kgmaint-1028a" {
t.Fatalf("索引/桶没取到源的身份:%+v", config)
}
if len(config.Writers) != 1 || config.Writers[0] != "app" || config.NonWriters["postgres"] == "" {
t.Fatalf("写入者声明没透传:%+v", config)
}
// 后台静默检查必须连"正在被封的源"。
if config.DatabaseURL != captureSource().DatabaseURL {
t.Fatalf("后台静默检查连错库")
}
}
// 没有闸门就没有排空证据:必须在开窗口之前拒绝,而不是退回"只锁库"的旧模式。
func TestCaptureMaintenanceConfigRefusesWithoutIngress(t *testing.T) {
env := maintenanceEnv()
delete(env, "LIBRARY_TARGET_INGRESS_URL")
_, err := captureMaintenanceConfig(captureOptions(), captureSource(), func(name string) string { return env[name] })
if err == nil || !strings.Contains(err.Error(), "LIBRARY_TARGET_INGRESS_URL") {
t.Fatalf("缺入口闸门没有被拒:%v", err)
}
}
+26
View File
@@ -45,6 +45,28 @@
同一个作业号重跑是**幂等重放**:直接返回已封存的同一份候选(`replay=true`、`source_read=false`、`writers_stopped=false`),不读来源、不停写入者;同一作业号换了 scope 或绑定库则明确拒绝。
## 实例级维护接线(封存窗口的唯一入口证据)
`capture` 与 `recover` 现在都按**实例级维护**开窗口,接线来自与 worker 同一组环境变量
(名字只在 `internal/librarymaintenance/env.go` 出现一次):
```
LIBRARY_TARGET_INSTANCE 实例名(写进维护记录与目标指纹)
LIBRARY_TARGET_INGRESS_URL 入口地址(恢复后要能 200)
LIBRARY_TARGET_INGRESS_SERVICE Compose 里的入口闸门服务名
LIBRARY_TARGET_INGRESS_DRAIN_FILE 闸门写排空证据的宿主机文件
LIBRARY_TARGET_REDIS_ADDR/_PASSWORD/_DB 后台队列(asynq)地址
```
为什么不再保留"库级窗口":被代理的 WeKnora 收到 SIGTERM 会直接 `exit(1)`,写者退出码
永远不能当作"在途请求已排空"。排空证据只能在真正的入口上取——关闸门、等它把在途连接
放完(`drain.json` 里的 `active_at_close` / `drained`)。因此缺少上面任何一项时 `capture`
会**在开窗口前拒绝**(报出缺少的变量名),而不是退回一个没有排空证据的窗口。
开窗口的顺序仍是:关入口并等在途排空 → 证明后台静默(稳定窗口 + 任务集合指纹)→ 停写入者
→ 停后复核队列/指纹与入口 → 导出 → 恢复写入者与入口。停写者后仍会**再查一次**源是否结清,
所以窗口内派生的真实任务必须真的跑完,否则封存被拒(不是等一会儿就算了)。
## 从页面发起封存(主机侧 agent)
页面点「发起对内/对外封存」只是**投递一个作业**;执行发生在这台主机上的 agent:
@@ -52,6 +74,10 @@
- `scripts/library-agent-install.sh` 构建并安装 `libraryagent`(systemd **user** 服务),
监听 `~/.local/share/knowledge-governance/agent/agent.sock`,目录 0700、socket 0600,
并额外用 `SO_PEERCRED` 要求对端与 agent 同 uid。
- 维护接线由 `~/.local/share/knowledge-governance/agent.env`(0600,`EnvironmentFile=`)提供;
没有这个文件安装脚本直接拒绝(`AGENT_ENV_MISSING`),免得装出一个"没有闸门"的 agent。
源容器/项目/写入者声明可用 `AGENT_SOURCE_APP`、`AGENT_SOURCE_PG`、`AGENT_SOURCE_ES`、
`AGENT_PROJECT`、`AGENT_WRITERS`、`AGENT_NON_WRITERS` 覆盖,默认是本机 `weknora071full`。
- agent 自己固定 `libraryctl` 的路径、归档与日志根、Compose 项目与写入者声明;
请求只能选 `scope`(internal/external)和作业号,**不能**指定路径、项目、容器或命令。
- 只有 worker 通过挂载的 socket 目录投递作业(`LIBRARY_AGENT_SOCKET`)。容器里
+91
View File
@@ -0,0 +1,91 @@
package librarymaintenance
import (
"errors"
"fmt"
"strconv"
"strings"
)
// 目标入口闸门与后台队列的声明名称。
//
// 为什么集中在这里:容器内的 worker 与主机侧的封存(cmd/libraryctl)都要讲
// 同一份"这个实例的入口在哪、队列在哪";名字写两遍迟早会分叉,而分叉的表现
// 是"一边关的是 A 实例的门、另一边读的是 B 实例的队列"——不会报错,只会给出
// 错的证据。
const (
EnvInstance = "LIBRARY_TARGET_INSTANCE"
EnvIngressURL = "LIBRARY_TARGET_INGRESS_URL"
EnvIngressService = "LIBRARY_TARGET_INGRESS_SERVICE"
EnvIngressDrainFile = "LIBRARY_TARGET_INGRESS_DRAIN_FILE"
EnvRedisAddr = "LIBRARY_TARGET_REDIS_ADDR"
EnvRedisPassword = "LIBRARY_TARGET_REDIS_PASSWORD"
EnvRedisDB = "LIBRARY_TARGET_REDIS_DB"
)
// TargetEnv 是一次实例级维护所需的"入口 + 队列"声明。
//
// 缺任何一项都不许降级成"库级维护":没有闸门就没有"入口已关、在途已排空"
// 的证据,没有队列就证明不了停写者时后台是空的。这两条正是被代理的 WeKnora
// 收到 SIGTERM 就 exit(1) 之后唯一还能证明"请求没被截断"的东西。
type TargetEnv struct {
Instance string
IngressURL string
IngressService string
IngressDrainFile string
RedisAddr string
RedisPassword string
RedisDB int
}
// TargetFromEnv 解析维护目标声明。缺少的项一次性全部报出来(含变量名),
// 让部署方一次改对,而不是改一项再撞下一项。
func TargetFromEnv(get func(string) string) (TargetEnv, error) {
if get == nil {
return TargetEnv{}, errors.New("缺少配置读取入口")
}
value := func(name string) string { return strings.TrimSpace(get(name)) }
target := TargetEnv{
Instance: value(EnvInstance),
IngressURL: value(EnvIngressURL),
IngressService: value(EnvIngressService),
IngressDrainFile: value(EnvIngressDrainFile),
RedisAddr: value(EnvRedisAddr),
// 口令不 Trim:前后空格也是口令的一部分,Trim 会把能连上的配置变成连不上。
RedisPassword: get(EnvRedisPassword),
}
missing := make([]string, 0, 5)
for _, item := range []struct {
name string
current string
}{
{EnvInstance, target.Instance},
{EnvIngressURL, target.IngressURL},
{EnvIngressService, target.IngressService},
{EnvIngressDrainFile, target.IngressDrainFile},
{EnvRedisAddr, target.RedisAddr},
} {
if item.current == "" {
missing = append(missing, item.name)
}
}
if strings.TrimSpace(target.RedisPassword) == "" {
missing = append(missing, EnvRedisPassword)
}
rawDB := value(EnvRedisDB)
switch {
case rawDB == "":
missing = append(missing, EnvRedisDB)
default:
db, err := strconv.Atoi(rawDB)
if err != nil || db < 0 {
return TargetEnv{}, fmt.Errorf("%s 不是有效的队列库号(%q)", EnvRedisDB, rawDB)
}
target.RedisDB = db
}
if len(missing) > 0 {
return TargetEnv{}, fmt.Errorf("实例级维护未接线:缺少 %s(没有入口闸门与队列就无法证明入口已排空、后台已静默)",
strings.Join(missing, "、"))
}
return target, nil
}
+80
View File
@@ -0,0 +1,80 @@
package librarymaintenance
import (
"strings"
"testing"
)
func completeEnv() map[string]string {
return map[string]string{
EnvInstance: "kgmaint-weknora",
EnvIngressURL: "http://127.0.0.1:18080/health",
EnvIngressService: "ingress",
EnvIngressDrainFile: "/tmp/gate/drain.json",
EnvRedisAddr: "127.0.0.1:26379",
EnvRedisPassword: "secret",
EnvRedisDB: "0",
}
}
func lookup(values map[string]string) func(string) string {
return func(name string) string { return values[name] }
}
func TestTargetFromEnvReadsTheWholeDeclaration(t *testing.T) {
target, err := TargetFromEnv(lookup(completeEnv()))
if err != nil {
t.Fatalf("完整声明被拒:%v", err)
}
if target.Instance != "kgmaint-weknora" || target.IngressService != "ingress" ||
target.IngressURL != "http://127.0.0.1:18080/health" || target.IngressDrainFile != "/tmp/gate/drain.json" ||
target.RedisAddr != "127.0.0.1:26379" || target.RedisPassword != "secret" || target.RedisDB != 0 {
t.Fatalf("解析结果不对:%+v", target)
}
}
// 每个缺失项都必须点名报出来:部署方要一次改对,而不是"改一项再撞下一项"。
func TestTargetFromEnvNamesEveryMissingItem(t *testing.T) {
for _, name := range []string{EnvInstance, EnvIngressURL, EnvIngressService,
EnvIngressDrainFile, EnvRedisAddr, EnvRedisPassword, EnvRedisDB} {
values := completeEnv()
delete(values, name)
if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("缺 %s 没有被点名:%v", name, err)
}
}
}
// 空白不能当成"配了":空口令会让 queuers 连不上,却在日志里显示"已接线"。
func TestTargetFromEnvRejectsBlankValues(t *testing.T) {
values := completeEnv()
values[EnvIngressURL] = " "
if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), EnvIngressURL) {
t.Fatalf("空白入口地址没有被拒:%v", err)
}
}
// 口令前后空格是口令的一部分:Trim 会把能连上的配置变成连不上。
func TestTargetFromEnvKeepsPasswordWhitespace(t *testing.T) {
values := completeEnv()
values[EnvRedisPassword] = " pass with space "
target, err := TargetFromEnv(lookup(values))
if err != nil {
t.Fatalf("带空格的口令被拒:%v", err)
}
if target.RedisPassword != " pass with space " {
t.Fatalf("口令被改写了:%q", target.RedisPassword)
}
}
func TestTargetFromEnvRejectsInvalidRedisDB(t *testing.T) {
values := completeEnv()
values[EnvRedisDB] = "-1"
if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), EnvRedisDB) {
t.Fatalf("负数库号没有被拒:%v", err)
}
values[EnvRedisDB] = "zero"
if _, err := TargetFromEnv(lookup(values)); err == nil || !strings.Contains(err.Error(), EnvRedisDB) {
t.Fatalf("非数字库号没有被拒:%v", err)
}
}
+23 -2
View File
@@ -9,8 +9,24 @@ set -euo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
UNIT_DIR="$HOME/.config/systemd/user"
SOCKET_DIR="$HOME/.local/share/knowledge-governance/agent"
# 实例级维护接线(LIBRARY_TARGET_*)与源声明一起由这一个 0600 文件提供;
# 它属于"这台主机上的哪个实例",不属于任何一次请求。
ENV_FILE="${AGENT_ENV_FILE:-$HOME/.local/share/knowledge-governance/agent.env}"
SOURCE_APP="${AGENT_SOURCE_APP:-WKNFull-app}"
SOURCE_PG="${AGENT_SOURCE_PG:-WKNFull-postgres}"
SOURCE_ES="${AGENT_SOURCE_ES:-WKNFull-elasticsearch}"
PROJECT="${AGENT_PROJECT:-weknora071full}"
WRITERS="${AGENT_WRITERS:-app}"
NON_WRITERS="${AGENT_NON_WRITERS:-frontend=UI only; no knowledge writes,mcp=read-only MCP server,dobotai-target=alpine placeholder; no knowledge writes,docreader=parse RPC service; app persists parse output,elasticsearch=search store; not an initiator,postgres=database store; not an initiator,redis=queue/cache store; not an initiator,neo4j=graph store; not an initiator,langfuse-web=LLM observability UI,langfuse-worker=LLM observability worker; own storage,langfuse-clickhouse=observability store,langfuse-minio=observability object store,langfuse-db-init=one-shot init; exited,searxng=read-only search proxy,searxng-init=one-shot init; exited}"
mkdir -p "$UNIT_DIR" "$SOCKET_DIR"
chmod 700 "$SOCKET_DIR"
if [ ! -f "$ENV_FILE" ]; then
# 宁可不装,也不要装出一个"没有闸门"的 agent:封存会在开窗口前被拒。
echo "AGENT_ENV_MISSING: $ENV_FILE" >&2
echo " write LIBRARY_TARGET_{INSTANCE,INGRESS_URL,INGRESS_SERVICE,INGRESS_DRAIN_FILE,REDIS_ADDR,REDIS_PASSWORD,REDIS_DB} there (0600)" >&2
exit 1
fi
chmod 600 "$ENV_FILE"
echo "=== build libraryctl + libraryagent ==="
(cd "$ROOT" && go build -o libraryctl ./cmd/libraryctl && go build -o libraryagent ./cmd/libraryagent)
@@ -24,13 +40,18 @@ After=network.target
[Service]
Type=simple
WorkingDirectory=$ROOT
EnvironmentFile=$ENV_FILE
ExecStart=$ROOT/libraryagent \\
-socket $SOCKET_DIR/agent.sock \\
-binary $ROOT/libraryctl \\
-archive $HOME/.local/share/knowledge-governance/library \\
-journal $HOME/.local/share/knowledge-governance/freeze \\
-project weknora071full \\
-writers app
-source-app $SOURCE_APP \\
-source-pg $SOURCE_PG \\
-source-es $SOURCE_ES \\
-project $PROJECT \\
-writers $WRITERS \\
-non-writers "$NON_WRITERS"
Restart=always
RestartSec=5