diff --git a/docs/roadmap.md b/docs/roadmap.md index d8f3c307c..a06d1d8d8 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 文件持久化实现,`feat/client-edge-store-file-flag-delicious233` 已接入 Edge 启动参数 `--store-file`,`feat/client-runner-process-adapter-delicious233` 已补本地进程 executor 边界,下一步重点是真实 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 边界,`feat/client-runner-workdir-delicious233` 已补本地进程工作目录边界,下一步重点是真实 Runner adapter。 ## 路线图分层 @@ -26,7 +26,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 文件持久化实现和 `--store-file` 启动参数已补齐,SQLite 仍是后续可选评估项。 -- [ ] M3 真实 Runner:CLI Agent 进程、取消、日志、错误映射。本地进程 executor 边界已补齐,但还不是 Claude Code / Codex / OpenCode 的完整 adapter。 +- [ ] M3 真实 Runner:CLI Agent 进程、取消、日志、错误映射。本地进程 executor 和本地进程工作目录边界已补齐,但还不是 Claude Code / Codex / OpenCode 的完整 adapter。 - [ ] 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,`feat/client-edge-store-file-flag-delicious233` 将文件 store 接入 Edge 启动入口,`feat/client-runner-process-adapter-delicious233` 增加可测试的本地进程 executor,后续继续做真实 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,`feat/client-runner-workdir-delicious233` 增加本地进程工作目录配置,后续继续做真实 Runner adapter。 ## 验收门槛 @@ -51,6 +51,7 @@ - [x] 为 Edge 启动入口接入可选 `--store-file `,未传时继续使用内存 store。 - [x] 在客户端 M2 基础上补 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。 - [x] 增加 Edge 本地进程 executor 边界,支持 stdout/stderr、成功、失败和取消事件映射。 +- [x] 为 Edge 本地进程 executor 增加工作目录配置边界;这只是本地进程 workdir 能力,不是完整真实 Runner adapter。 - [ ] 将 Runner 真正接入 Edge Run lifecycle,替换 handler 内置 mock flow。 - [ ] M2 完成后归档或更新 `docs/client-roadmap.md`,避免路线图重复。 - [ ] 为 Runner 真实 CLI adapter 规划最小测试夹具。 diff --git a/docs/roadmaps/branches/feat-client-runner-workdir-delicious233.md b/docs/roadmaps/branches/feat-client-runner-workdir-delicious233.md new file mode 100644 index 000000000..46dde2a60 --- /dev/null +++ b/docs/roadmaps/branches/feat-client-runner-workdir-delicious233.md @@ -0,0 +1,35 @@ +# feat/client-runner-workdir-delicious233 路线图 + +最后更新:2026-05-23 + +## 当前目标 + +- [x] 为 Edge 本地进程 executor 增加工作目录配置边界,为后续真实 Claude Code / Codex / OpenCode adapter 在指定 workspace 中运行做准备。 + +## 写入范围 + +- `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-workdir-delicious233.md` + +## 已完成 + +- [x] `ProcessExecutorConfig` 增加 `WorkDir`,非空时构造期验证必须存在且是目录。 +- [x] `ProcessExecutor` 启动子进程时设置 `cmd.Dir`。 +- [x] `agenthub-edge` 增加 `--runner-workdir`,并拒绝没有 `--runner-command` 的无效组合。 +- [x] 使用 Go 自进程 helper 覆盖子进程实际工作目录,不依赖 shell 或真实 Agent CLI。 +- [x] 路线图标明本分支只完成本地进程工作目录边界,不是完整真实 Runner adapter。 + +## 下一步 + +- [ ] 在后续真实 Runner adapter 分支中接入具体 Claude Code / Codex / OpenCode 命令和 workspace 选择策略。 + +## 验收 + +- [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 ./...` diff --git a/docs/roadmaps/client.md b/docs/roadmaps/client.md index 997861a2d..e49f94f8b 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 文件持久化实现,`feat/client-edge-store-file-flag-delicious233` 已将文件 store 接入 Edge 启动参数,`feat/client-runner-process-adapter-delicious233` 已补本地进程 executor 边界,后续继续补真实 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 边界,`feat/client-runner-workdir-delicious233` 已补本地进程工作目录配置边界,后续继续补真实 Runner adapter。 ## 近期任务 @@ -29,6 +29,7 @@ - [x] 补齐 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。 - [x] 抽出 Edge Run lifecycle executor 边界,替换 handler 内置 mock flow。 - [x] 增加可测试的本地进程 executor,覆盖 stdout/stderr 输出、正常退出、非零退出、取消和重复启动。 +- [x] 增加本地进程工作目录配置,覆盖构造期目录验证和子进程实际运行目录;这不是完整 Claude Code / Codex / OpenCode adapter。 - [ ] 将真实 Runner adapter 接入 Edge Run lifecycle。 - [ ] 细化 Project / Thread / Item 的 OpenAPI 响应 schema。 diff --git a/edge-server/cmd/agenthub-edge/main.go b/edge-server/cmd/agenthub-edge/main.go index e4b8a16d0..418ef9044 100644 --- a/edge-server/cmd/agenthub-edge/main.go +++ b/edge-server/cmd/agenthub-edge/main.go @@ -17,6 +17,7 @@ type config struct { StoreFile string RunnerCommand string RunnerArgs repeatedString + RunnerWorkDir string } type repeatedString []string @@ -52,6 +53,7 @@ func main() { serverConfig.ProcessExecutor = lifecycle.ProcessExecutorConfig{ Command: cfg.RunnerCommand, Args: append([]string(nil), cfg.RunnerArgs...), + WorkDir: cfg.RunnerWorkDir, } } @@ -69,6 +71,7 @@ func buildConfig(args []string) (config, error) { 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.StringVar(&cfg.RunnerWorkDir, "runner-workdir", "", "working directory for --runner-command; empty inherits the edge process working directory") fs.Var(&cfg.RunnerArgs, "runner-arg", "argument passed to --runner-command; may be repeated") if err := fs.Parse(args); err != nil { return config{}, err @@ -77,6 +80,9 @@ func buildConfig(args []string) (config, error) { if cfg.RunnerCommand == "" && len(cfg.RunnerArgs) > 0 { return config{}, fmt.Errorf("--runner-arg requires --runner-command") } + if cfg.RunnerCommand == "" && cfg.RunnerWorkDir != "" { + return config{}, fmt.Errorf("--runner-workdir requires --runner-command") + } if fs.NArg() != 0 { return config{}, fmt.Errorf("unexpected positional arguments: %v", fs.Args()) } diff --git a/edge-server/cmd/agenthub-edge/main_test.go b/edge-server/cmd/agenthub-edge/main_test.go index 6c31c6602..c979b8615 100644 --- a/edge-server/cmd/agenthub-edge/main_test.go +++ b/edge-server/cmd/agenthub-edge/main_test.go @@ -24,6 +24,9 @@ func TestBuildConfigDefaultsToMemoryStore(t *testing.T) { if cfg.RunnerCommand != "" { t.Fatalf("RunnerCommand = %q, want empty", cfg.RunnerCommand) } + if cfg.RunnerWorkDir != "" { + t.Fatalf("RunnerWorkDir = %q, want empty", cfg.RunnerWorkDir) + } if len(cfg.RunnerArgs) != 0 { t.Fatalf("RunnerArgs = %#v, want empty", cfg.RunnerArgs) } @@ -34,6 +37,7 @@ func TestBuildConfigParsesStoreFile(t *testing.T) { "--addr", "127.0.0.1:4321", "--store-file", "edge-store.json", "--runner-command", "agenthub-runner", + "--runner-workdir", "workspace", "--runner-arg", "--mock", "--runner-arg", "--addr=127.0.0.1:0", }) @@ -50,6 +54,9 @@ func TestBuildConfigParsesStoreFile(t *testing.T) { if cfg.RunnerCommand != "agenthub-runner" { t.Fatalf("RunnerCommand = %q, want parsed command", cfg.RunnerCommand) } + if cfg.RunnerWorkDir != "workspace" { + t.Fatalf("RunnerWorkDir = %q, want parsed path", cfg.RunnerWorkDir) + } 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) } @@ -69,6 +76,13 @@ func TestBuildConfigRejectsRunnerArgsWithoutCommand(t *testing.T) { } } +func TestBuildConfigRejectsRunnerWorkDirWithoutCommand(t *testing.T) { + _, err := buildConfig([]string{"--runner-workdir", "workspace"}) + if err == nil || !strings.Contains(err.Error(), "--runner-workdir 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 { diff --git a/edge-server/internal/lifecycle/process_executor.go b/edge-server/internal/lifecycle/process_executor.go index 219b2101c..cab725675 100644 --- a/edge-server/internal/lifecycle/process_executor.go +++ b/edge-server/internal/lifecycle/process_executor.go @@ -21,6 +21,7 @@ type ProcessExecutorConfig struct { Command string Args []string Env []string + WorkDir string } type ProcessExecutor struct { @@ -29,6 +30,7 @@ type ProcessExecutor struct { command string args []string env []string + workDir string mu sync.Mutex running map[string]context.CancelFunc @@ -44,12 +46,22 @@ func NewProcessExecutor(bus *events.Bus, store store.RunLifecycleStore, cfg Proc if cfg.Command == "" { return nil, ErrProcessCommandRequired } + if cfg.WorkDir != "" { + info, err := os.Stat(cfg.WorkDir) + if err != nil { + return nil, fmt.Errorf("process workdir %q is not accessible: %w", cfg.WorkDir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("process workdir %q is not a directory", cfg.WorkDir) + } + } return &ProcessExecutor{ bus: bus, store: store, command: cfg.Command, args: append([]string(nil), cfg.Args...), env: append([]string(nil), cfg.Env...), + workDir: cfg.WorkDir, running: make(map[string]context.CancelFunc), }, nil } @@ -110,6 +122,7 @@ func (e *ProcessExecutor) run(ctx context.Context, run store.Run) { defer e.finish(run.ID) cmd := exec.CommandContext(ctx, e.command, e.args...) + cmd.Dir = e.workDir cmd.Env = e.envForRun(run) stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/edge-server/internal/lifecycle/process_executor_test.go b/edge-server/internal/lifecycle/process_executor_test.go index eb4385891..fecfbaa31 100644 --- a/edge-server/internal/lifecycle/process_executor_test.go +++ b/edge-server/internal/lifecycle/process_executor_test.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "testing" "time" @@ -30,6 +31,43 @@ func TestProcessExecutorRequiresDependencies(t *testing.T) { } } +func TestProcessExecutorRejectsInvalidWorkDir(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "not-a-directory") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + tests := []struct { + name string + workDir string + want string + }{ + { + name: "missing", + workDir: filepath.Join(tempDir, "missing"), + want: "is not accessible", + }, + { + name: "file", + workDir: filePath, + want: "is not a directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewProcessExecutor(events.NewBus(10), store.New(), ProcessExecutorConfig{ + Command: os.Args[0], + WorkDir: tt.workDir, + }) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("NewProcessExecutor error = %v, want containing %q", err, tt.want) + } + }) + } +} + func TestProcessExecutorRejectsMissingRun(t *testing.T) { bus := events.NewBus(100) s := store.New() @@ -114,6 +152,60 @@ func TestProcessExecutorPublishesOutputAndFinished(t *testing.T) { } } +func TestProcessExecutorRunsCommandInConfiguredWorkDir(t *testing.T) { + bus := events.NewBus(100) + s := store.New() + run := newExecutorTestRun(t, s) + workDir := filepath.Join(t.TempDir(), "workspace") + if err := os.Mkdir(workDir, 0o755); err != nil { + t.Fatalf("Mkdir returned error: %v", err) + } + _, ch, _ := bus.Subscribe(0) + executor, err := NewProcessExecutor(bus, s, ProcessExecutorConfig{ + Command: os.Args[0], + Args: []string{"-test.run=TestProcessExecutorHelper", "--", "pwd"}, + Env: append(os.Environ(), "AGENTHUB_PROCESS_EXECUTOR_HELPER=1"), + WorkDir: workDir, + }) + if err != nil { + t.Fatalf("NewProcessExecutor returned error: %v", err) + } + + if err := executor.Start(run); err != nil { + t.Fatalf("Start returned error: %v", err) + } + + var stdoutText string + for { + evt := nextEvent(t, ch) + switch evt.Type { + case "run.started": + case "run.output.batch": + payload, ok := evt.Payload.(map[string]any) + if !ok { + t.Fatalf("output payload = %T, want map", evt.Payload) + } + if payload["stream"] != "stdout" { + continue + } + chunks, ok := payload["chunks"].([]map[string]any) + if !ok || len(chunks) == 0 { + t.Fatalf("output chunks = %#v, want non-empty []map[string]any", payload["chunks"]) + } + text, _ := chunks[0]["text"].(string) + stdoutText += text + case "run.finished": + want := "cwd=" + filepath.Clean(workDir) + if !strings.Contains(stdoutText, want) { + t.Fatalf("stdout text = %q, want %q", stdoutText, want) + } + return + default: + t.Fatalf("unexpected event type %q", evt.Type) + } + } +} + func TestProcessExecutorPublishesFailedForNonZeroExit(t *testing.T) { bus := events.NewBus(100) s := store.New() @@ -270,6 +362,13 @@ func TestProcessExecutorHelper(t *testing.T) { os.Exit(7) case "sleep": time.Sleep(5 * time.Second) + case "pwd": + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "getwd: %v\n", err) + os.Exit(3) + } + fmt.Fprintf(os.Stdout, "cwd=%s\n", filepath.Clean(cwd)) default: fmt.Fprintf(os.Stderr, "unknown helper mode %q\n", mode) os.Exit(2)