Files
knowledge-governance/internal/libraryagent/client.go
T
姚章浩 6ed06357f9
build / build (push) Successful in 1m27s
fix(library): 期望执行器身份随 capture 请求送达并在执行前核对
原来 worker 先 health 再 capture,两次拨号之间 socket 可能被换成另一个实例的
执行器:错的 agent 已经把写入者停了、快照也出了,事后 postcheck 只能拒绝登记,
副作用无法撤销。

现在 capture 请求自带期望身份(治理侧配置生成:project 必填,source_app /
archive_root 配了就绑定)与覆盖三项的稳定指纹;接收方在操作互斥内、执行任何源
操作之前比对,缺字段、指纹与字段不一致、project/source_app 不符都直接拒绝,
**错的执行器一次 capture 都不跑**。封存结果回显该指纹,worker 事后复核按它对照,
postcheck 只作为证据防线。恢复路径不要求期望(它只把写入者恢复回来)。

负例是确定性的:两个真实 agent 各占一个 socket,把调用方指向错的那个,断言它的
capture 调用为 0 且仍能回答 health;同一份期望打到对的 socket 则只跑一次。
2026-10-29 13:23:56 +08:00

108 lines
3.6 KiB
Go

package libraryagent
import (
"context"
"encoding/json"
"errors"
"net"
"time"
)
// ClientConfig is what a governance process needs: one socket path. It never
// carries source credentials or Docker access — those stay with the agent.
type ClientConfig struct {
Socket string
Timeout time.Duration
}
// Result is the agent's answer to one capture/status/recover request.
type Result struct {
OK bool
Scope string
Job string
SnapshotID string
Replay bool
Error string
Payload json.RawMessage
}
// Capture asks the agent to seal one library scope. The job id makes the call
// idempotent: repeating it returns the already sealed candidate.
func Capture(ctx context.Context, config ClientConfig, scope, job string, expect Expectation) (Result, error) {
return CaptureReleased(ctx, config, scope, job, nil, expect)
}
// CaptureReleased 在 Capture 之上带上治理平台已审计的精确死信解除指纹。
func CaptureReleased(ctx context.Context, config ClientConfig, scope, job string, released []string, expect Expectation) (Result, error) {
request := map[string]any{"action": "capture", "scope": scope, "job": job, "expect": expect}
if len(released) > 0 {
request["released"] = released
}
return call(ctx, config, request)
}
// Status probes the agent (source wiring, writer group, archive usage).
func Status(ctx context.Context, config ClientConfig) (Result, error) {
return call(ctx, config, map[string]any{"action": "status"})
}
// Health 只问三件事:这个执行器指向谁(project/容器/归档)、是否在跑、是否降级。
// 它不跑子进程、不占操作锁,所以可以当心跳用;Status 会去查源库,代价高得多。
func Health(ctx context.Context, config ClientConfig) (Result, error) {
return call(ctx, config, map[string]any{"action": "health"})
}
// Recover restores writers from an interrupted freeze journal.
func Recover(ctx context.Context, config ClientConfig) (Result, error) {
return call(ctx, config, map[string]any{"action": "recover"})
}
func call(ctx context.Context, config ClientConfig, request map[string]any) (Result, error) {
if config.Socket == "" {
return Result{}, errors.New("library agent socket is not configured")
}
timeout := config.Timeout
if timeout <= 0 {
timeout = 2 * time.Minute
}
bounded, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var dialer net.Dialer
conn, err := dialer.DialContext(bounded, "unix", config.Socket)
if err != nil {
return Result{}, errors.New("library agent is unreachable")
}
defer conn.Close()
if deadline, ok := bounded.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
if err = json.NewEncoder(conn).Encode(request); err != nil {
return Result{}, errors.New("library agent request failed")
}
var response struct {
OK bool `json:"ok"`
Scope string `json:"scope"`
Job string `json:"job"`
Error string `json:"error"`
Payload json.RawMessage `json:"payload"`
}
if err = json.NewDecoder(conn).Decode(&response); err != nil {
return Result{}, errors.New("library agent returned an invalid response")
}
result := Result{OK: response.OK, Scope: response.Scope, Job: response.Job, Error: response.Error, Payload: response.Payload}
var payload struct {
SnapshotID string `json:"snapshot_id"`
Replay bool `json:"replay"`
}
if len(response.Payload) > 0 && json.Unmarshal(response.Payload, &payload) == nil {
result.SnapshotID, result.Replay = payload.SnapshotID, payload.Replay
}
if !result.OK {
if result.Error == "" {
result.Error = "library agent refused the request"
}
return result, errors.New(result.Error)
}
return result, nil
}