feat(hitl): 增量1 —— JetStream KV checkpoint store(持久化中断地基)

HITL 持久化中断/恢复的地基:compose checkpoint 需要一个可持久化、抗重启的
存储后端。dispatcher 是"只说 NATS、无 DB"的纯净设计,故复用既有 JetStream
(bus.js)开 KV 桶,不引 Redis、不破坏架构原则。

- shared/bus: 新增 KVHandle + Bus.Checkpoints(bucket, ttl)。薄封装把 NATS
  细节(ErrKeyNotFound→ok=false、Delete 幂等)挡在 bus 内,对外是朴素
  Get/Put/Delete;bus 无需反向依赖 eino。File 存储 + 桶级 TTL 兜底清理。
- dispatcher/eino: checkpointStore 把 CheckpointKV 适配成 compose.CheckPointStore
  (Get/Set + 可选 Delete)。CheckpointKV 是最小接口,bus.KVHandle 结构化满足。
- 测试: 内存桩往返(Set→Get→Delete→miss)+ 编译期契约断言
  `var _ CheckpointKV = (*bus.KVHandle)(nil)` 钉死 bus↔eino 隐式契约。

零爆炸半径(纯新增)。go test ./... 全绿。
下一步增量2:审批节点改 compose.Interrupt + Orchestrator 识别中断置 waiting。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-29 11:39:20 +08:00
parent 03225e31a9
commit f3bf42c432
4 changed files with 364 additions and 1 deletions
@@ -0,0 +1,67 @@
package eino
import (
"context"
"sync"
"testing"
"github.com/sundynix/sundynix-shared/bus"
)
// memKV 是 CheckpointKV 的内存桩(并发安全),用于不依赖 NATS 的单测。
type memKV struct {
mu sync.Mutex
m map[string][]byte
}
func newMemKV() *memKV { return &memKV{m: map[string][]byte{}} }
func (k *memKV) Get(_ context.Context, key string) ([]byte, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
v, ok := k.m[key]
return v, ok, nil
}
func (k *memKV) Put(_ context.Context, key string, val []byte) error {
k.mu.Lock()
defer k.mu.Unlock()
k.m[key] = append([]byte(nil), val...)
return nil
}
func (k *memKV) Delete(_ context.Context, key string) error {
k.mu.Lock()
defer k.mu.Unlock()
delete(k.m, key)
return nil
}
// bus.KVHandleJetStream KV 后端)须结构化满足 CheckpointKV——钉死 bus 与 eino 的隐式契约,
// 任一侧改了 Get/Put/Delete 签名都会在这里编译失败。
var _ CheckpointKV = (*bus.KVHandle)(nil)
// TestCheckpointStoreRoundtrip 验证 compose.CheckPointStore 适配:Set→Get 命中、Delete 后 Get 落空。
func TestCheckpointStoreRoundtrip(t *testing.T) {
s := newCheckpointStore(newMemKV())
ctx := context.Background()
const id = "task_42"
if _, ok, err := s.Get(ctx, id); err != nil || ok {
t.Fatalf("空 store 应未命中: ok=%v err=%v", ok, err)
}
cp := []byte("compose-serialized-state")
if err := s.Set(ctx, id, cp); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok, err := s.Get(ctx, id)
if err != nil || !ok || string(got) != string(cp) {
t.Fatalf("Get 应回放原状态: got=%q ok=%v err=%v", got, ok, err)
}
if err := s.Delete(ctx, id); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, ok, _ := s.Get(ctx, id); ok {
t.Fatalf("Delete 后不应再命中")
}
}