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 文件持久化实现,`feat/client-edge-store-file-flag-delicious233` 已接入 Edge 启动参数 `--store-file`,下一步重点是真实 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`,`feat/client-runner-process-adapter-delicious233` 已补本地进程 executor 边界,下一步重点是真实 Runner adapter。

## 路线图分层

Expand All @@ -26,15 +26,15 @@

- [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 文件持久化实现和 `--store-file` 启动参数已补齐,SQLite 仍是后续可选评估项。
- [ ] M3 真实 Runner:CLI Agent 进程、取消、日志、错误映射。
- [ ] M3 真实 Runner:CLI Agent 进程、取消、日志、错误映射。本地进程 executor 边界已补齐,但还不是 Claude Code / Codex / OpenCode 的完整 adapter。
- [ ] M4 Workspace 能力:worktree、diff、preview、artifact、approval。
- [ ] M5 Hub 协作链路:Edge-Hub sync、远程查看、远程审批。

## 当前活跃方向

- 前端:从 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,`feat/client-edge-store-file-flag-delicious233` 将文件 store 接入 Edge 启动入口,后续继续做真实 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 启动入口,`feat/client-runner-process-adapter-delicious233` 增加可测试的本地进程 executor,后续继续做真实 Runner adapter。

## 验收门槛

Expand All @@ -50,6 +50,7 @@
- [x] 增加 Edge store 轻量 JSON 文件持久化实现;SQLite 依赖获取问题保留为后续可选评估。
- [x] 为 Edge 启动入口接入可选 `--store-file <path>`,未传时继续使用内存 store。
- [x] 在客户端 M2 基础上补 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。
- [x] 增加 Edge 本地进程 executor 边界,支持 stdout/stderr、成功、失败和取消事件映射。
- [ ] 将 Runner 真正接入 Edge Run lifecycle,替换 handler 内置 mock flow。
- [ ] M2 完成后归档或更新 `docs/client-roadmap.md`,避免路线图重复。
- [ ] 为 Runner 真实 CLI adapter 规划最小测试夹具。
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# feat/client-runner-process-adapter-delicious233 路线图

最后更新:2026-05-23

## 当前目标

- [x] 为 Edge Run lifecycle 增加可测试的本地进程 executor。
- [x] 保持未配置 runner 命令时仍使用 MockExecutor。
- [x] 记录这是本地进程 executor 边界,不是完整真实 CLI adapter。

## 写入范围

- `edge-server/internal/lifecycle/`
- `edge-server/cmd/agenthub-edge/`
- `edge-server/internal/httpserver/`
- `docs/roadmap.md`
- `docs/roadmaps/client.md`
- `docs/roadmaps/branches/feat-client-runner-process-adapter-delicious233.md`

## 已完成

- 新增 `ProcessExecutor`,通过 `exec.CommandContext` 启动可配置命令。
- 将 stdout / stderr 转为 `run.output.batch` 事件。
- 正常退出发布 `run.finished`,非零退出发布 `run.failed`,取消发布 `run.cancelled`。
- 增加并发 run map,重复启动返回 `ErrRunAlreadyStarted`,`Cancel(runID)` 可取消运行中的进程。
- `agenthub-edge` 增加 `--runner-command` 和可重复 `--runner-arg`,未配置时仍走 MockExecutor。
- 子进程会收到 `AGENTHUB_RUN_ID` / `AGENTHUB_PROJECT_ID` / `AGENTHUB_THREAD_ID`,用于后续真实 adapter 读取执行上下文。
- 修复交叉 review 发现的边界:缺失 run 不会启动外部进程,nil bus/store 会在构造期返回错误。
- 测试使用 Go test 自进程 helper,不依赖 shell、PowerShell、Claude Code、Codex 或 OpenCode。

## 验收

- [x] `cd edge-server; go test -count=1 ./internal/lifecycle ./cmd/agenthub-edge ./internal/httpserver`
- [x] 交叉 review 发现 1 个 High、1 个 Medium,均已修复。
- [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 任务中定义 CLI 入参、工作目录、环境变量、路径保护和错误映射。
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 文件持久化实现,`feat/client-edge-store-file-flag-delicious233` 已将文件 store 接入 Edge 启动参数,后续继续补真实 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 启动参数,`feat/client-runner-process-adapter-delicious233` 已补本地进程 executor 边界,后续继续补真实 Runner adapter。

## 近期任务

Expand All @@ -28,6 +28,7 @@
- [ ] 后续按需要评估 SQLite 持久化方案。
- [x] 补齐 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。
- [x] 抽出 Edge Run lifecycle executor 边界,替换 handler 内置 mock flow。
- [x] 增加可测试的本地进程 executor,覆盖 stdout/stderr 输出、正常退出、非零退出、取消和重复启动。
- [ ] 将真实 Runner adapter 接入 Edge Run lifecycle。
- [ ] 细化 Project / Thread / Item 的 OpenAPI 响应 schema。

Expand Down
35 changes: 32 additions & 3 deletions edge-server/cmd/agenthub-edge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,29 @@ import (
"fmt"
"log/slog"
"os"
"strings"

"github.com/agenthub/edge-server/internal/httpserver"
"github.com/agenthub/edge-server/internal/lifecycle"
"github.com/agenthub/edge-server/internal/store"
)

type config struct {
Addr string
StoreFile string
Addr string
StoreFile string
RunnerCommand string
RunnerArgs repeatedString
}

type repeatedString []string

func (v *repeatedString) String() string {
return fmt.Sprint([]string(*v))
}

func (v *repeatedString) Set(value string) error {
*v = append(*v, value)
return nil
}

func main() {
Expand All @@ -32,7 +47,15 @@ func main() {
os.Exit(1)
}

if err := httpserver.Run(httpserver.Config{Addr: cfg.Addr, Store: repository}); err != nil {
serverConfig := httpserver.Config{Addr: cfg.Addr, Store: repository}
if cfg.RunnerCommand != "" {
serverConfig.ProcessExecutor = lifecycle.ProcessExecutorConfig{
Command: cfg.RunnerCommand,
Args: append([]string(nil), cfg.RunnerArgs...),
}
}

if err := httpserver.Run(serverConfig); err != nil {
slog.Error("server exited with error", "err", err)
os.Exit(1)
}
Expand All @@ -45,9 +68,15 @@ func buildConfig(args []string) (config, error) {
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")
fs.StringVar(&cfg.RunnerCommand, "runner-command", "", "local process command to execute for each run; empty uses the mock executor")
fs.Var(&cfg.RunnerArgs, "runner-arg", "argument passed to --runner-command; may be repeated")
if err := fs.Parse(args); err != nil {
return config{}, err
}
cfg.RunnerCommand = strings.TrimSpace(cfg.RunnerCommand)
if cfg.RunnerCommand == "" && len(cfg.RunnerArgs) > 0 {
return config{}, fmt.Errorf("--runner-arg requires --runner-command")
}
if fs.NArg() != 0 {
return config{}, fmt.Errorf("unexpected positional arguments: %v", fs.Args())
}
Expand Down
27 changes: 26 additions & 1 deletion edge-server/cmd/agenthub-edge/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,22 @@ func TestBuildConfigDefaultsToMemoryStore(t *testing.T) {
if cfg.StoreFile != "" {
t.Fatalf("StoreFile = %q, want empty", cfg.StoreFile)
}
if cfg.RunnerCommand != "" {
t.Fatalf("RunnerCommand = %q, want empty", cfg.RunnerCommand)
}
if len(cfg.RunnerArgs) != 0 {
t.Fatalf("RunnerArgs = %#v, want empty", cfg.RunnerArgs)
}
}

func TestBuildConfigParsesStoreFile(t *testing.T) {
cfg, err := buildConfig([]string{"--addr", "127.0.0.1:4321", "--store-file", "edge-store.json"})
cfg, err := buildConfig([]string{
"--addr", "127.0.0.1:4321",
"--store-file", "edge-store.json",
"--runner-command", "agenthub-runner",
"--runner-arg", "--mock",
"--runner-arg", "--addr=127.0.0.1:0",
})
if err != nil {
t.Fatalf("buildConfig returned error: %v", err)
}
Expand All @@ -35,6 +47,12 @@ func TestBuildConfigParsesStoreFile(t *testing.T) {
if cfg.StoreFile != "edge-store.json" {
t.Fatalf("StoreFile = %q, want parsed path", cfg.StoreFile)
}
if cfg.RunnerCommand != "agenthub-runner" {
t.Fatalf("RunnerCommand = %q, want parsed command", cfg.RunnerCommand)
}
if got, want := []string(cfg.RunnerArgs), []string{"--mock", "--addr=127.0.0.1:0"}; strings.Join(got, "\x00") != strings.Join(want, "\x00") {
t.Fatalf("RunnerArgs = %#v, want %#v", got, want)
}
}

func TestBuildConfigRejectsUnexpectedArguments(t *testing.T) {
Expand All @@ -44,6 +62,13 @@ func TestBuildConfigRejectsUnexpectedArguments(t *testing.T) {
}
}

func TestBuildConfigRejectsRunnerArgsWithoutCommand(t *testing.T) {
_, err := buildConfig([]string{"--runner-arg", "--mock"})
if err == nil || !strings.Contains(err.Error(), "--runner-arg requires --runner-command") {
t.Fatalf("buildConfig error = %v, want runner command requirement", err)
}
}

func TestNewStoreFromConfigUsesMemoryStoreByDefault(t *testing.T) {
repository, err := newStoreFromConfig(config{})
if err != nil {
Expand Down
19 changes: 17 additions & 2 deletions edge-server/internal/httpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,45 @@ import (

"github.com/agenthub/edge-server/internal/api"
"github.com/agenthub/edge-server/internal/events"
"github.com/agenthub/edge-server/internal/lifecycle"
"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
Store store.Repository
Addr string
Store store.Repository
ProcessExecutor lifecycle.ProcessExecutorConfig
}

// Run starts the HTTP server and blocks until a shutdown signal is received.
func Run(cfg Config) error {
if cfg.Addr == "" {
cfg.Addr = "127.0.0.1:3210"
}
if cfg.Store == nil {
cfg.Store = store.New()
}

bus := events.NewBus(10000)
registry := runners.NewRegistry()

var executor lifecycle.RunExecutor
if cfg.ProcessExecutor.Command != "" {
processExecutor, err := lifecycle.NewProcessExecutor(bus, cfg.Store, cfg.ProcessExecutor)
if err != nil {
return err
}
executor = processExecutor
}

handler := &api.Handler{
Bus: bus,
Registry: registry,
Store: cfg.Store,
Executor: executor,
}

mux := http.NewServeMux()
Expand Down
Loading
Loading