Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 路线图分层

Expand All @@ -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、远程查看、远程审批。
Expand All @@ -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。

## 验收门槛

Expand All @@ -48,6 +48,7 @@

- [x] 抽象 Edge store 可替换接口边界。
- [x] 增加 Edge store 轻量 JSON 文件持久化实现;SQLite 依赖获取问题保留为后续可选评估。
- [x] 为 Edge 启动入口接入可选 `--store-file <path>`,未传时继续使用内存 store。
- [x] 在客户端 M2 基础上补 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。
- [ ] 将 Runner 真正接入 Edge Run lifecycle,替换 handler 内置 mock flow。
- [ ] M2 完成后归档或更新 `docs/client-roadmap.md`,避免路线图重复。
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <path>`,传入后使用 `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 持久化方案。
3 changes: 2 additions & 1 deletion docs/roadmaps/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 近期任务

Expand All @@ -24,6 +24,7 @@
- [x] 同步 `api/openapi.yaml`。
- [x] 抽象可替换 store 接口。
- [x] 实现轻量 JSON 文件持久化 store,验证 Edge 重启后 Project / Thread / Run / Item 可恢复。
- [x] 接入 Edge 启动参数 `--store-file <path>`,未传参数时仍使用内存 store。
- [ ] 后续按需要评估 SQLite 持久化方案。
- [x] 补齐 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。
- [x] 抽出 Edge Run lifecycle executor 边界,替换 handler 内置 mock flow。
Expand Down
51 changes: 47 additions & 4 deletions edge-server/cmd/agenthub-edge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
88 changes: 88 additions & 0 deletions edge-server/cmd/agenthub-edge/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion edge-server/internal/httpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,6 +34,7 @@ func Run(cfg Config) error {
handler := &api.Handler{
Bus: bus,
Registry: registry,
Store: cfg.Store,
}

mux := http.NewServeMux()
Expand Down
8 changes: 6 additions & 2 deletions edge-server/internal/store/file_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 24 additions & 4 deletions edge-server/internal/store/file_store_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package store

import (
"errors"
"os"
"path/filepath"
"strings"
Expand All @@ -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)
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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)
}
Expand Down
Loading