Skip to content

Commit cd67ffe

Browse files
feat(client): 接入 Edge 文件持久化启动参数
- Edge Server 支持可选 --store-file 启动参数。 - FileStore 启动期验证 snapshot 可写性。 - 验证通过:validate、edge-server go test、runner go test。
1 parent 5991902 commit cd67ffe

8 files changed

Lines changed: 213 additions & 15 deletions

File tree

docs/roadmap.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
## 当前总目标
66

7-
推进 M2 Edge 本地数据层,让前端、后端、客户端三条线能围绕稳定的 Project / Thread / Run / Item / Event 模型并行开发。当前客户端 PR #30 已提供内存态最小实现,`feat/client-thread-messages-delicious233` 已补 message/item 写入链路,`feat/client-run-lifecycle-delicious233` 已抽出 Runner lifecycle 边界,`feat/client-store-boundary-delicious233` 已抽象 Edge store 接口边界,`feat/client-store-persistence-delicious233` 已提供轻量 JSON 文件持久化实现,下一步重点是真实 Runner adapter。
7+
推进 M2 Edge 本地数据层,让前端、后端、客户端三条线能围绕稳定的 Project / Thread / Run / Item / Event 模型并行开发。当前客户端 PR #30 已提供内存态最小实现,`feat/client-thread-messages-delicious233` 已补 message/item 写入链路,`feat/client-run-lifecycle-delicious233` 已抽出 Runner lifecycle 边界,`feat/client-store-boundary-delicious233` 已抽象 Edge store 接口边界,`feat/client-store-persistence-delicious233` 已提供轻量 JSON 文件持久化实现,`feat/client-edge-store-file-flag-delicious233` 已接入 Edge 启动参数 `--store-file`下一步重点是真实 Runner adapter。
88

99
## 路线图分层
1010

@@ -25,7 +25,7 @@
2525
## 里程碑
2626

2727
- [x] M1 客户端本地链路:Desktop Shell + Local Edge + Mock Runner + smoke test。
28-
- [ ] M2 Edge 本地数据层:Project / Thread / Run / Item / EventStore。最小内存实现已在 PR #30,message/item 写入链路、Runner lifecycle 边界、store 接口边界和轻量 JSON 文件持久化实现已补齐,SQLite 仍是后续可选评估项。
28+
- [ ] M2 Edge 本地数据层:Project / Thread / Run / Item / EventStore。最小内存实现已在 PR #30,message/item 写入链路、Runner lifecycle 边界、store 接口边界、轻量 JSON 文件持久化实现和 `--store-file` 启动参数已补齐,SQLite 仍是后续可选评估项。
2929
- [ ] M3 真实 Runner:CLI Agent 进程、取消、日志、错误映射。
3030
- [ ] M4 Workspace 能力:worktree、diff、preview、artifact、approval。
3131
- [ ] M5 Hub 协作链路:Edge-Hub sync、远程查看、远程审批。
@@ -34,7 +34,7 @@
3434

3535
- 前端:从 Mock 数据过渡到真实 REST / WebSocket client,承接 UI 同学设计。
3636
- 后端:实现 Hub Server、Edge-Hub 通信、账号/群聊/同步/中继能力。
37-
- 客户端:PR #30 推进 Edge 本地数据层,`feat/client-thread-messages-delicious233` 补齐 message/item 写入链路,`feat/client-run-lifecycle-delicious233``feat/client-store-boundary-delicious233` 分别补齐 lifecycle/store 可替换边界,`feat/client-store-persistence-delicious233` 增加轻量 JSON 文件持久化 store,后续继续做真实 Runner adapter。
37+
- 客户端:PR #30 推进 Edge 本地数据层,`feat/client-thread-messages-delicious233` 补齐 message/item 写入链路,`feat/client-run-lifecycle-delicious233``feat/client-store-boundary-delicious233` 分别补齐 lifecycle/store 可替换边界,`feat/client-store-persistence-delicious233` 增加轻量 JSON 文件持久化 store,`feat/client-edge-store-file-flag-delicious233` 将文件 store 接入 Edge 启动入口,后续继续做真实 Runner adapter。
3838

3939
## 验收门槛
4040

@@ -48,6 +48,7 @@
4848

4949
- [x] 抽象 Edge store 可替换接口边界。
5050
- [x] 增加 Edge store 轻量 JSON 文件持久化实现;SQLite 依赖获取问题保留为后续可选评估。
51+
- [x] 为 Edge 启动入口接入可选 `--store-file <path>`,未传时继续使用内存 store。
5152
- [x] 在客户端 M2 基础上补 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。
5253
- [ ] 将 Runner 真正接入 Edge Run lifecycle,替换 handler 内置 mock flow。
5354
- [ ] M2 完成后归档或更新 `docs/client-roadmap.md`,避免路线图重复。
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# feat/client-edge-store-file-flag-delicious233 路线图
2+
3+
最后更新:2026-05-23
4+
5+
## 当前目标
6+
7+
- [x] 让 Edge Server 启动入口支持可选文件持久化 store。
8+
9+
## 写入范围
10+
11+
- `edge-server/cmd/agenthub-edge/`
12+
- `edge-server/internal/httpserver/`
13+
- `edge-server/internal/store/`
14+
- `docs/roadmap.md`
15+
- `docs/roadmaps/client.md`
16+
- `docs/roadmaps/branches/feat-client-edge-store-file-flag-delicious233.md`
17+
18+
## 已完成
19+
20+
- [x] 新增启动参数 `--store-file <path>`,传入后使用 `store.NewFile(path)`
21+
- [x] 未传 `--store-file` 时继续使用内存 store,保持默认启动行为。
22+
- [x]`httpserver.Config` 扩展为可注入 `store.Repository`,不改变 REST API 或 WebSocket event。
23+
- [x] 为启动参数解析和 store 构造逻辑补充单元测试,覆盖默认内存 store、文件 store 和坏 JSON 启动失败错误。
24+
- [x] 修复交叉 review 发现的启动期可写性缺口:`store.NewFile(path)` 加载 snapshot 后立即保存一次当前 snapshot,确保父目录可创建、目标文件可写、JSON 可编码。
25+
- [x] 补充父路径被普通文件占用的启动失败测试,避免 Windows 权限位差异导致用例不稳定。
26+
27+
## 验收
28+
29+
- [x] 局部 `cd edge-server; go test -count=1 ./internal/store ./cmd/agenthub-edge` 通过。
30+
- [x] 收口 `git diff --check` 通过。
31+
- [x] 收口 `python -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('api/openapi.yaml').read_text(encoding='utf-8')); print('yaml ok')"` 通过。
32+
- [x] 收口 `cd edge-server; go test -count=1 ./...` 通过。
33+
- [x] 收口 `cd runner; go test -count=1 ./...` 通过。
34+
35+
## 下一步
36+
37+
- [ ] 将真实 Runner adapter 接入 Edge Run lifecycle。
38+
- [ ] 后续按需要评估 SQLite 持久化方案。

docs/roadmaps/client.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
## 当前目标
1414

15-
推进 M2 Edge 本地数据层,把 M1 的内存事件流升级为 Project / Thread / Run / Item / Event 模型。当前 PR #30 已完成内存态最小模型,`feat/client-thread-messages-delicious233` 已补 message/item 写入链路,`feat/client-run-lifecycle-delicious233` 已抽出 Edge Run lifecycle executor 边界,`feat/client-store-boundary-delicious233` 已抽象可替换 store 接口,`feat/client-store-persistence-delicious233` 已提供轻量 JSON 文件持久化实现,后续继续补真实 Runner adapter。
15+
推进 M2 Edge 本地数据层,把 M1 的内存事件流升级为 Project / Thread / Run / Item / Event 模型。当前 PR #30 已完成内存态最小模型,`feat/client-thread-messages-delicious233` 已补 message/item 写入链路,`feat/client-run-lifecycle-delicious233` 已抽出 Edge Run lifecycle executor 边界,`feat/client-store-boundary-delicious233` 已抽象可替换 store 接口,`feat/client-store-persistence-delicious233` 已提供轻量 JSON 文件持久化实现,`feat/client-edge-store-file-flag-delicious233` 已将文件 store 接入 Edge 启动参数,后续继续补真实 Runner adapter。
1616

1717
## 近期任务
1818

@@ -24,6 +24,7 @@
2424
- [x] 同步 `api/openapi.yaml`
2525
- [x] 抽象可替换 store 接口。
2626
- [x] 实现轻量 JSON 文件持久化 store,验证 Edge 重启后 Project / Thread / Run / Item 可恢复。
27+
- [x] 接入 Edge 启动参数 `--store-file <path>`,未传参数时仍使用内存 store。
2728
- [ ] 后续按需要评估 SQLite 持久化方案。
2829
- [x] 补齐 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。
2930
- [x] 抽出 Edge Run lifecycle executor 边界,替换 handler 内置 mock flow。

edge-server/cmd/agenthub-edge/main.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,65 @@ package main
22

33
import (
44
"flag"
5+
"fmt"
56
"log/slog"
67
"os"
78

89
"github.com/agenthub/edge-server/internal/httpserver"
10+
"github.com/agenthub/edge-server/internal/store"
911
)
1012

11-
func main() {
12-
addr := flag.String("addr", "127.0.0.1:3210", "listen address")
13-
flag.Parse()
13+
type config struct {
14+
Addr string
15+
StoreFile string
16+
}
1417

18+
func main() {
1519
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
1620
Level: slog.LevelInfo,
1721
})))
1822

19-
if err := httpserver.Run(httpserver.Config{Addr: *addr}); err != nil {
23+
cfg, err := buildConfig(os.Args[1:])
24+
if err != nil {
25+
slog.Error("invalid configuration", "err", err)
26+
os.Exit(2)
27+
}
28+
29+
repository, err := newStoreFromConfig(cfg)
30+
if err != nil {
31+
slog.Error("failed to initialize store", "err", err)
32+
os.Exit(1)
33+
}
34+
35+
if err := httpserver.Run(httpserver.Config{Addr: cfg.Addr, Store: repository}); err != nil {
2036
slog.Error("server exited with error", "err", err)
2137
os.Exit(1)
2238
}
2339
}
40+
41+
func buildConfig(args []string) (config, error) {
42+
fs := flag.NewFlagSet("agenthub-edge", flag.ContinueOnError)
43+
fs.SetOutput(os.Stderr)
44+
45+
cfg := config{}
46+
fs.StringVar(&cfg.Addr, "addr", "127.0.0.1:3210", "listen address")
47+
fs.StringVar(&cfg.StoreFile, "store-file", "", "JSON store snapshot file path")
48+
if err := fs.Parse(args); err != nil {
49+
return config{}, err
50+
}
51+
if fs.NArg() != 0 {
52+
return config{}, fmt.Errorf("unexpected positional arguments: %v", fs.Args())
53+
}
54+
return cfg, nil
55+
}
56+
57+
func newStoreFromConfig(cfg config) (store.Repository, error) {
58+
if cfg.StoreFile == "" {
59+
return store.New(), nil
60+
}
61+
repository, err := store.NewFile(cfg.StoreFile)
62+
if err != nil {
63+
return nil, fmt.Errorf("open store file %q: %w", cfg.StoreFile, err)
64+
}
65+
return repository, nil
66+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/agenthub/edge-server/internal/store"
10+
)
11+
12+
func TestBuildConfigDefaultsToMemoryStore(t *testing.T) {
13+
cfg, err := buildConfig(nil)
14+
if err != nil {
15+
t.Fatalf("buildConfig returned error: %v", err)
16+
}
17+
18+
if cfg.Addr != "127.0.0.1:3210" {
19+
t.Fatalf("Addr = %q, want default listen address", cfg.Addr)
20+
}
21+
if cfg.StoreFile != "" {
22+
t.Fatalf("StoreFile = %q, want empty", cfg.StoreFile)
23+
}
24+
}
25+
26+
func TestBuildConfigParsesStoreFile(t *testing.T) {
27+
cfg, err := buildConfig([]string{"--addr", "127.0.0.1:4321", "--store-file", "edge-store.json"})
28+
if err != nil {
29+
t.Fatalf("buildConfig returned error: %v", err)
30+
}
31+
32+
if cfg.Addr != "127.0.0.1:4321" {
33+
t.Fatalf("Addr = %q, want parsed address", cfg.Addr)
34+
}
35+
if cfg.StoreFile != "edge-store.json" {
36+
t.Fatalf("StoreFile = %q, want parsed path", cfg.StoreFile)
37+
}
38+
}
39+
40+
func TestBuildConfigRejectsUnexpectedArguments(t *testing.T) {
41+
_, err := buildConfig([]string{"unexpected"})
42+
if err == nil || !strings.Contains(err.Error(), "unexpected positional arguments") {
43+
t.Fatalf("buildConfig error = %v, want unexpected positional arguments error", err)
44+
}
45+
}
46+
47+
func TestNewStoreFromConfigUsesMemoryStoreByDefault(t *testing.T) {
48+
repository, err := newStoreFromConfig(config{})
49+
if err != nil {
50+
t.Fatalf("newStoreFromConfig returned error: %v", err)
51+
}
52+
if _, ok := repository.(*store.Store); !ok {
53+
t.Fatalf("repository type = %T, want *store.Store", repository)
54+
}
55+
}
56+
57+
func TestNewStoreFromConfigUsesFileStore(t *testing.T) {
58+
path := filepath.Join(t.TempDir(), "edge-store.json")
59+
60+
repository, err := newStoreFromConfig(config{StoreFile: path})
61+
if err != nil {
62+
t.Fatalf("newStoreFromConfig returned error: %v", err)
63+
}
64+
fileStore, ok := repository.(*store.FileStore)
65+
if !ok {
66+
t.Fatalf("repository type = %T, want *store.FileStore", repository)
67+
}
68+
69+
fileStore.CreateProject("proj_test", "Test Project")
70+
if _, err := os.Stat(path); err != nil {
71+
t.Fatalf("store file was not written: %v", err)
72+
}
73+
}
74+
75+
func TestNewStoreFromConfigReturnsFileStoreErrors(t *testing.T) {
76+
path := filepath.Join(t.TempDir(), "edge-store.json")
77+
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
78+
t.Fatalf("WriteFile returned error: %v", err)
79+
}
80+
81+
_, err := newStoreFromConfig(config{StoreFile: path})
82+
if err == nil {
83+
t.Fatal("newStoreFromConfig returned nil error for invalid file store")
84+
}
85+
if !strings.Contains(err.Error(), "open store file") || !strings.Contains(err.Error(), "decode store snapshot") {
86+
t.Fatalf("newStoreFromConfig error = %v, want clear store file decode error", err)
87+
}
88+
}

edge-server/internal/httpserver/server.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import (
1313
"github.com/agenthub/edge-server/internal/events"
1414
"github.com/agenthub/edge-server/internal/runners"
1515
"github.com/agenthub/edge-server/internal/security"
16+
"github.com/agenthub/edge-server/internal/store"
1617
)
1718

1819
// Config holds server configuration.
1920
type Config struct {
20-
Addr string
21+
Addr string
22+
Store store.Repository
2123
}
2224

2325
// Run starts the HTTP server and blocks until a shutdown signal is received.
@@ -32,6 +34,7 @@ func Run(cfg Config) error {
3234
handler := &api.Handler{
3335
Bus: bus,
3436
Registry: registry,
37+
Store: cfg.Store,
3538
}
3639

3740
mux := http.NewServeMux()

edge-server/internal/store/file_store.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,14 @@ func NewFile(path string) (*FileStore, error) {
4545
return nil, err
4646
}
4747

48-
return &FileStore{
48+
f := &FileStore{
4949
path: path,
5050
store: s,
51-
}, nil
51+
}
52+
if err := f.persist(); err != nil {
53+
return nil, fmt.Errorf("verify store snapshot write: %w", err)
54+
}
55+
return f, nil
5256
}
5357

5458
func (f *FileStore) LastPersistError() error {

edge-server/internal/store/file_store_test.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package store
22

33
import (
4-
"errors"
54
"os"
65
"path/filepath"
76
"strings"
@@ -11,7 +10,7 @@ import (
1110
var _ Repository = (*FileStore)(nil)
1211
var _ RunLifecycleStore = (*FileStore)(nil)
1312

14-
func TestFileStoreStartsEmptyWhenFileDoesNotExist(t *testing.T) {
13+
func TestFileStoreStartsEmptyAndCreatesSnapshotWhenFileDoesNotExist(t *testing.T) {
1514
path := filepath.Join(t.TempDir(), "store.json")
1615

1716
s, err := NewFile(path)
@@ -21,8 +20,8 @@ func TestFileStoreStartsEmptyWhenFileDoesNotExist(t *testing.T) {
2120
if got := s.ListProjects(); len(got) != 0 {
2221
t.Fatalf("ListProjects = %#v, want empty", got)
2322
}
24-
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
25-
t.Fatalf("snapshot file exists before first write or stat failed: %v", err)
23+
if _, err := os.Stat(path); err != nil {
24+
t.Fatalf("snapshot file was not created during startup verification: %v", err)
2625
}
2726
}
2827

@@ -101,6 +100,24 @@ func TestFileStoreRejectsBadJSON(t *testing.T) {
101100
}
102101
}
103102

103+
func TestFileStoreRejectsUnwritableSnapshotPathOnStartup(t *testing.T) {
104+
dir := t.TempDir()
105+
blocker := filepath.Join(dir, "blocker")
106+
if err := os.WriteFile(blocker, []byte("not a directory"), 0o644); err != nil {
107+
t.Fatalf("WriteFile returned error: %v", err)
108+
}
109+
path := filepath.Join(blocker, "edge-store.json")
110+
111+
_, err := NewFile(path)
112+
if err == nil {
113+
t.Fatal("NewFile returned nil error for blocked snapshot directory")
114+
}
115+
if !strings.Contains(err.Error(), "verify store snapshot write") ||
116+
!strings.Contains(err.Error(), "create store snapshot directory") {
117+
t.Fatalf("NewFile error = %v, want startup write verification directory error", err)
118+
}
119+
}
120+
104121
func TestFileStoreTreatsEmptySnapshotAsEmptyStore(t *testing.T) {
105122
tests := map[string]string{
106123
"zero_bytes": "",
@@ -259,6 +276,9 @@ func TestFileStoreLastPersistErrorTracksSaveFailure(t *testing.T) {
259276
if err != nil {
260277
t.Fatalf("NewFile returned error: %v", err)
261278
}
279+
if err := os.Remove(path); err != nil {
280+
t.Fatalf("Remove returned error: %v", err)
281+
}
262282
if err := os.Mkdir(path, 0o755); err != nil {
263283
t.Fatalf("Mkdir returned error: %v", err)
264284
}

0 commit comments

Comments
 (0)