From a1c00390313cbe71aeaf8e4c01772c867f84b641 Mon Sep 17 00:00:00 2001 From: Delicious233 Date: Sat, 23 May 2026 04:54:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E6=8E=A5=E5=85=A5=20Edge=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=8C=81=E4=B9=85=E5=8C=96=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/roadmap.md | 7 +- ...lient-edge-store-file-flag-delicious233.md | 38 ++++++++ docs/roadmaps/client.md | 3 +- edge-server/cmd/agenthub-edge/main.go | 51 ++++++++++- edge-server/cmd/agenthub-edge/main_test.go | 88 +++++++++++++++++++ edge-server/internal/httpserver/server.go | 5 +- edge-server/internal/store/file_store.go | 8 +- edge-server/internal/store/file_store_test.go | 28 +++++- 8 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 docs/roadmaps/branches/feat-client-edge-store-file-flag-delicious233.md create mode 100644 edge-server/cmd/agenthub-edge/main_test.go diff --git a/docs/roadmap.md b/docs/roadmap.md index 288b6ec8a..24f397ca8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,7 +4,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。 +推进 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。 ## 路线图分层 @@ -25,7 +25,7 @@ ## 里程碑 - [x] M1 客户端本地链路:Desktop Shell + Local Edge + Mock Runner + smoke test。 -- [ ] M2 Edge 本地数据层:Project / Thread / Run / Item / EventStore。最小内存实现已在 PR #30,message/item 写入链路、Runner lifecycle 边界、store 接口边界和轻量 JSON 文件持久化实现已补齐,SQLite 仍是后续可选评估项。 +- [ ] M2 Edge 本地数据层:Project / Thread / Run / Item / EventStore。最小内存实现已在 PR #30,message/item 写入链路、Runner lifecycle 边界、store 接口边界、轻量 JSON 文件持久化实现和 `--store-file` 启动参数已补齐,SQLite 仍是后续可选评估项。 - [ ] M3 真实 Runner:CLI Agent 进程、取消、日志、错误映射。 - [ ] M4 Workspace 能力:worktree、diff、preview、artifact、approval。 - [ ] M5 Hub 协作链路:Edge-Hub sync、远程查看、远程审批。 @@ -34,7 +34,7 @@ - 前端:从 Mock 数据过渡到真实 REST / WebSocket client,承接 UI 同学设计。 - 后端:实现 Hub Server、Edge-Hub 通信、账号/群聊/同步/中继能力。 -- 客户端: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。 +- 客户端: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。 ## 验收门槛 @@ -48,6 +48,7 @@ - [x] 抽象 Edge store 可替换接口边界。 - [x] 增加 Edge store 轻量 JSON 文件持久化实现;SQLite 依赖获取问题保留为后续可选评估。 +- [x] 为 Edge 启动入口接入可选 `--store-file `,未传时继续使用内存 store。 - [x] 在客户端 M2 基础上补 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。 - [ ] 将 Runner 真正接入 Edge Run lifecycle,替换 handler 内置 mock flow。 - [ ] M2 完成后归档或更新 `docs/client-roadmap.md`,避免路线图重复。 diff --git a/docs/roadmaps/branches/feat-client-edge-store-file-flag-delicious233.md b/docs/roadmaps/branches/feat-client-edge-store-file-flag-delicious233.md new file mode 100644 index 000000000..3f99d521b --- /dev/null +++ b/docs/roadmaps/branches/feat-client-edge-store-file-flag-delicious233.md @@ -0,0 +1,38 @@ +# feat/client-edge-store-file-flag-delicious233 路线图 + +最后更新:2026-05-23 + +## 当前目标 + +- [x] 让 Edge Server 启动入口支持可选文件持久化 store。 + +## 写入范围 + +- `edge-server/cmd/agenthub-edge/` +- `edge-server/internal/httpserver/` +- `edge-server/internal/store/` +- `docs/roadmap.md` +- `docs/roadmaps/client.md` +- `docs/roadmaps/branches/feat-client-edge-store-file-flag-delicious233.md` + +## 已完成 + +- [x] 新增启动参数 `--store-file `,传入后使用 `store.NewFile(path)`。 +- [x] 未传 `--store-file` 时继续使用内存 store,保持默认启动行为。 +- [x] 将 `httpserver.Config` 扩展为可注入 `store.Repository`,不改变 REST API 或 WebSocket event。 +- [x] 为启动参数解析和 store 构造逻辑补充单元测试,覆盖默认内存 store、文件 store 和坏 JSON 启动失败错误。 +- [x] 修复交叉 review 发现的启动期可写性缺口:`store.NewFile(path)` 加载 snapshot 后立即保存一次当前 snapshot,确保父目录可创建、目标文件可写、JSON 可编码。 +- [x] 补充父路径被普通文件占用的启动失败测试,避免 Windows 权限位差异导致用例不稳定。 + +## 验收 + +- [x] 局部 `cd edge-server; go test -count=1 ./internal/store ./cmd/agenthub-edge` 通过。 +- [x] 收口 `git diff --check` 通过。 +- [x] 收口 `python -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('api/openapi.yaml').read_text(encoding='utf-8')); print('yaml ok')"` 通过。 +- [x] 收口 `cd edge-server; go test -count=1 ./...` 通过。 +- [x] 收口 `cd runner; go test -count=1 ./...` 通过。 + +## 下一步 + +- [ ] 将真实 Runner adapter 接入 Edge Run lifecycle。 +- [ ] 后续按需要评估 SQLite 持久化方案。 diff --git a/docs/roadmaps/client.md b/docs/roadmaps/client.md index 3c59610c9..86f686d2e 100644 --- a/docs/roadmaps/client.md +++ b/docs/roadmaps/client.md @@ -12,7 +12,7 @@ ## 当前目标 -推进 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。 +推进 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。 ## 近期任务 @@ -24,6 +24,7 @@ - [x] 同步 `api/openapi.yaml`。 - [x] 抽象可替换 store 接口。 - [x] 实现轻量 JSON 文件持久化 store,验证 Edge 重启后 Project / Thread / Run / Item 可恢复。 +- [x] 接入 Edge 启动参数 `--store-file `,未传参数时仍使用内存 store。 - [ ] 后续按需要评估 SQLite 持久化方案。 - [x] 补齐 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。 - [x] 抽出 Edge Run lifecycle executor 边界,替换 handler 内置 mock flow。 diff --git a/edge-server/cmd/agenthub-edge/main.go b/edge-server/cmd/agenthub-edge/main.go index 9af42009c..624826b8a 100644 --- a/edge-server/cmd/agenthub-edge/main.go +++ b/edge-server/cmd/agenthub-edge/main.go @@ -2,22 +2,65 @@ package main import ( "flag" + "fmt" "log/slog" "os" "github.com/agenthub/edge-server/internal/httpserver" + "github.com/agenthub/edge-server/internal/store" ) -func main() { - addr := flag.String("addr", "127.0.0.1:3210", "listen address") - flag.Parse() +type config struct { + Addr string + StoreFile string +} +func main() { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, }))) - if err := httpserver.Run(httpserver.Config{Addr: *addr}); err != nil { + cfg, err := buildConfig(os.Args[1:]) + if err != nil { + slog.Error("invalid configuration", "err", err) + os.Exit(2) + } + + repository, err := newStoreFromConfig(cfg) + if err != nil { + slog.Error("failed to initialize store", "err", err) + os.Exit(1) + } + + if err := httpserver.Run(httpserver.Config{Addr: cfg.Addr, Store: repository}); err != nil { slog.Error("server exited with error", "err", err) os.Exit(1) } } + +func buildConfig(args []string) (config, error) { + fs := flag.NewFlagSet("agenthub-edge", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + cfg := config{} + fs.StringVar(&cfg.Addr, "addr", "127.0.0.1:3210", "listen address") + fs.StringVar(&cfg.StoreFile, "store-file", "", "JSON store snapshot file path") + if err := fs.Parse(args); err != nil { + return config{}, err + } + if fs.NArg() != 0 { + return config{}, fmt.Errorf("unexpected positional arguments: %v", fs.Args()) + } + return cfg, nil +} + +func newStoreFromConfig(cfg config) (store.Repository, error) { + if cfg.StoreFile == "" { + return store.New(), nil + } + repository, err := store.NewFile(cfg.StoreFile) + if err != nil { + return nil, fmt.Errorf("open store file %q: %w", cfg.StoreFile, err) + } + return repository, nil +} diff --git a/edge-server/cmd/agenthub-edge/main_test.go b/edge-server/cmd/agenthub-edge/main_test.go new file mode 100644 index 000000000..5554eab4e --- /dev/null +++ b/edge-server/cmd/agenthub-edge/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/agenthub/edge-server/internal/store" +) + +func TestBuildConfigDefaultsToMemoryStore(t *testing.T) { + cfg, err := buildConfig(nil) + if err != nil { + t.Fatalf("buildConfig returned error: %v", err) + } + + if cfg.Addr != "127.0.0.1:3210" { + t.Fatalf("Addr = %q, want default listen address", cfg.Addr) + } + if cfg.StoreFile != "" { + t.Fatalf("StoreFile = %q, want empty", cfg.StoreFile) + } +} + +func TestBuildConfigParsesStoreFile(t *testing.T) { + cfg, err := buildConfig([]string{"--addr", "127.0.0.1:4321", "--store-file", "edge-store.json"}) + if err != nil { + t.Fatalf("buildConfig returned error: %v", err) + } + + if cfg.Addr != "127.0.0.1:4321" { + t.Fatalf("Addr = %q, want parsed address", cfg.Addr) + } + if cfg.StoreFile != "edge-store.json" { + t.Fatalf("StoreFile = %q, want parsed path", cfg.StoreFile) + } +} + +func TestBuildConfigRejectsUnexpectedArguments(t *testing.T) { + _, err := buildConfig([]string{"unexpected"}) + if err == nil || !strings.Contains(err.Error(), "unexpected positional arguments") { + t.Fatalf("buildConfig error = %v, want unexpected positional arguments error", err) + } +} + +func TestNewStoreFromConfigUsesMemoryStoreByDefault(t *testing.T) { + repository, err := newStoreFromConfig(config{}) + if err != nil { + t.Fatalf("newStoreFromConfig returned error: %v", err) + } + if _, ok := repository.(*store.Store); !ok { + t.Fatalf("repository type = %T, want *store.Store", repository) + } +} + +func TestNewStoreFromConfigUsesFileStore(t *testing.T) { + path := filepath.Join(t.TempDir(), "edge-store.json") + + repository, err := newStoreFromConfig(config{StoreFile: path}) + if err != nil { + t.Fatalf("newStoreFromConfig returned error: %v", err) + } + fileStore, ok := repository.(*store.FileStore) + if !ok { + t.Fatalf("repository type = %T, want *store.FileStore", repository) + } + + fileStore.CreateProject("proj_test", "Test Project") + if _, err := os.Stat(path); err != nil { + t.Fatalf("store file was not written: %v", err) + } +} + +func TestNewStoreFromConfigReturnsFileStoreErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), "edge-store.json") + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + _, err := newStoreFromConfig(config{StoreFile: path}) + if err == nil { + t.Fatal("newStoreFromConfig returned nil error for invalid file store") + } + if !strings.Contains(err.Error(), "open store file") || !strings.Contains(err.Error(), "decode store snapshot") { + t.Fatalf("newStoreFromConfig error = %v, want clear store file decode error", err) + } +} diff --git a/edge-server/internal/httpserver/server.go b/edge-server/internal/httpserver/server.go index f95e0482a..8148078c6 100644 --- a/edge-server/internal/httpserver/server.go +++ b/edge-server/internal/httpserver/server.go @@ -13,11 +13,13 @@ import ( "github.com/agenthub/edge-server/internal/events" "github.com/agenthub/edge-server/internal/runners" "github.com/agenthub/edge-server/internal/security" + "github.com/agenthub/edge-server/internal/store" ) // Config holds server configuration. type Config struct { - Addr string + Addr string + Store store.Repository } // Run starts the HTTP server and blocks until a shutdown signal is received. @@ -32,6 +34,7 @@ func Run(cfg Config) error { handler := &api.Handler{ Bus: bus, Registry: registry, + Store: cfg.Store, } mux := http.NewServeMux() diff --git a/edge-server/internal/store/file_store.go b/edge-server/internal/store/file_store.go index 81f3fe4bb..897d59eb0 100644 --- a/edge-server/internal/store/file_store.go +++ b/edge-server/internal/store/file_store.go @@ -45,10 +45,14 @@ func NewFile(path string) (*FileStore, error) { return nil, err } - return &FileStore{ + f := &FileStore{ path: path, store: s, - }, nil + } + if err := f.persist(); err != nil { + return nil, fmt.Errorf("verify store snapshot write: %w", err) + } + return f, nil } func (f *FileStore) LastPersistError() error { diff --git a/edge-server/internal/store/file_store_test.go b/edge-server/internal/store/file_store_test.go index 5bda7a0f4..2344c0540 100644 --- a/edge-server/internal/store/file_store_test.go +++ b/edge-server/internal/store/file_store_test.go @@ -1,7 +1,6 @@ package store import ( - "errors" "os" "path/filepath" "strings" @@ -11,7 +10,7 @@ import ( var _ Repository = (*FileStore)(nil) var _ RunLifecycleStore = (*FileStore)(nil) -func TestFileStoreStartsEmptyWhenFileDoesNotExist(t *testing.T) { +func TestFileStoreStartsEmptyAndCreatesSnapshotWhenFileDoesNotExist(t *testing.T) { path := filepath.Join(t.TempDir(), "store.json") s, err := NewFile(path) @@ -21,8 +20,8 @@ func TestFileStoreStartsEmptyWhenFileDoesNotExist(t *testing.T) { if got := s.ListProjects(); len(got) != 0 { t.Fatalf("ListProjects = %#v, want empty", got) } - if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("snapshot file exists before first write or stat failed: %v", err) + if _, err := os.Stat(path); err != nil { + t.Fatalf("snapshot file was not created during startup verification: %v", err) } } @@ -101,6 +100,24 @@ func TestFileStoreRejectsBadJSON(t *testing.T) { } } +func TestFileStoreRejectsUnwritableSnapshotPathOnStartup(t *testing.T) { + dir := t.TempDir() + blocker := filepath.Join(dir, "blocker") + if err := os.WriteFile(blocker, []byte("not a directory"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + path := filepath.Join(blocker, "edge-store.json") + + _, err := NewFile(path) + if err == nil { + t.Fatal("NewFile returned nil error for blocked snapshot directory") + } + if !strings.Contains(err.Error(), "verify store snapshot write") || + !strings.Contains(err.Error(), "create store snapshot directory") { + t.Fatalf("NewFile error = %v, want startup write verification directory error", err) + } +} + func TestFileStoreTreatsEmptySnapshotAsEmptyStore(t *testing.T) { tests := map[string]string{ "zero_bytes": "", @@ -259,6 +276,9 @@ func TestFileStoreLastPersistErrorTracksSaveFailure(t *testing.T) { if err != nil { t.Fatalf("NewFile returned error: %v", err) } + if err := os.Remove(path); err != nil { + t.Fatalf("Remove returned error: %v", err) + } if err := os.Mkdir(path, 0o755); err != nil { t.Fatalf("Mkdir returned error: %v", err) }