Skip to content

Commit b0d83cd

Browse files
feat(client): 增加 Runner adapter profile
合入客户端 Runner adapter profile 最小层:支持 Run 上下文展开 args/env,保留环境继承,并更新路线图。
1 parent d10e91b commit b0d83cd

8 files changed

Lines changed: 508 additions & 26 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 文件持久化实现,`feat/client-edge-store-file-flag-delicious233` 已接入 Edge 启动参数 `--store-file``feat/client-runner-process-adapter-delicious233` 已补本地进程 executor 边界,`feat/client-runner-workdir-delicious233` 已补本地进程工作目录边界,下一步重点是真实 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``feat/client-runner-process-adapter-delicious233` 已补本地进程 executor 边界,`feat/client-runner-workdir-delicious233` 已补本地进程工作目录边界,`feat/client-runner-adapter-profile-delicious233` 已补 generic adapter profile / 命令模板最小层,下一步重点是真实 Runner adapter。
88

99
## 路线图分层
1010

@@ -26,15 +26,15 @@
2626

2727
- [x] M1 客户端本地链路:Desktop Shell + Local Edge + Mock Runner + smoke test。
2828
- [ ] M2 Edge 本地数据层:Project / Thread / Run / Item / EventStore。最小内存实现已在 PR #30,message/item 写入链路、Runner lifecycle 边界、store 接口边界、轻量 JSON 文件持久化实现和 `--store-file` 启动参数已补齐,SQLite 仍是后续可选评估项。
29-
- [ ] M3 真实 Runner:CLI Agent 进程、取消、日志、错误映射。本地进程 executor 和本地进程工作目录边界已补齐,但还不是 Claude Code / Codex / OpenCode 的完整 adapter。
29+
- [ ] M3 真实 Runner:CLI Agent 进程、取消、日志、错误映射。本地进程 executor、本地进程工作目录边界和 generic adapter profile / 命令模板最小层已补齐,但还不是 Claude Code / Codex / OpenCode 的完整 adapter。
3030
- [ ] M4 Workspace 能力:worktree、diff、preview、artifact、approval。
3131
- [ ] M5 Hub 协作链路:Edge-Hub sync、远程查看、远程审批。
3232

3333
## 当前活跃方向
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,`feat/client-edge-store-file-flag-delicious233` 将文件 store 接入 Edge 启动入口,`feat/client-runner-process-adapter-delicious233` 增加可测试的本地进程 executor,`feat/client-runner-workdir-delicious233` 增加本地进程工作目录配置,后续继续做真实 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 启动入口,`feat/client-runner-process-adapter-delicious233` 增加可测试的本地进程 executor,`feat/client-runner-workdir-delicious233` 增加本地进程工作目录配置,`feat/client-runner-adapter-profile-delicious233` 增加可测试的 generic adapter profile / 命令模板最小层,后续继续做真实 Runner adapter。
3838

3939
## 验收门槛
4040

@@ -52,6 +52,7 @@
5252
- [x] 在客户端 M2 基础上补 `POST /v1/threads/{threadId}/messages` 到 Item / event 的写入链路。
5353
- [x] 增加 Edge 本地进程 executor 边界,支持 stdout/stderr、成功、失败和取消事件映射。
5454
- [x] 为 Edge 本地进程 executor 增加工作目录配置边界;这只是本地进程 workdir 能力,不是完整真实 Runner adapter。
55+
- [x] 为 Edge 本地进程 executor 增加 generic adapter profile / 命令模板最小层,支持从 Run 上下文展开 args/env。
5556
- [ ] 将 Runner 真正接入 Edge Run lifecycle,替换 handler 内置 mock flow。
5657
- [ ] M2 完成后归档或更新 `docs/client-roadmap.md`,避免路线图重复。
5758
- [ ] 为 Runner 真实 CLI adapter 规划最小测试夹具。
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# feat/client-runner-adapter-profile-delicious233 路线图
2+
3+
最后更新:2026-05-23
4+
5+
## 当前目标
6+
7+
- [x] 为 Edge ProcessExecutor 增加 generic adapter profile / 命令模板最小层,支持从 Run 上下文展开 CLI args/env。
8+
9+
## 写入范围
10+
11+
- `edge-server/internal/lifecycle/`
12+
- `edge-server/cmd/agenthub-edge/`
13+
- `docs/roadmap.md`
14+
- `docs/roadmaps/client.md`
15+
- `docs/roadmaps/branches/feat-client-runner-adapter-profile-delicious233.md`
16+
17+
## 已完成
18+
19+
- [x] 新增 `RunnerProfile``CommandTemplate``RunProcessContext` 内部边界。
20+
- [x] 支持 `{{run.id}}``{{run.projectId}}``{{run.threadId}}` 在 args/env 中展开。
21+
- [x] 保留 `--runner-command``--runner-arg``--runner-workdir` 行为,并新增 repeatable `--runner-env KEY=VALUE`
22+
- [x] 构造期校验空 command、未知占位符和 env 格式错误。
23+
- [x] 完成交叉 review 后修复环境继承、env 错误脱敏和 `ExtraEnv` 边界,系统环境原样继承,只有显式 `--runner-env` 参与模板展开。
24+
25+
## 下一步
26+
27+
- [ ] 后续分支接入 Claude Code / Codex / OpenCode 具体 adapter profile。
28+
29+
## 验收
30+
31+
- [x] `git diff --check`
32+
- [x] `python -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('api/openapi.yaml').read_text(encoding='utf-8')); print('yaml ok')"`
33+
- [x] `cd edge-server; go test -count=1 ./...`
34+
- [x] `cd runner; go test -count=1 ./...`

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 文件持久化实现,`feat/client-edge-store-file-flag-delicious233` 已将文件 store 接入 Edge 启动参数,`feat/client-runner-process-adapter-delicious233` 已补本地进程 executor 边界,`feat/client-runner-workdir-delicious233` 已补本地进程工作目录配置边界,后续继续补真实 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 启动参数,`feat/client-runner-process-adapter-delicious233` 已补本地进程 executor 边界,`feat/client-runner-workdir-delicious233` 已补本地进程工作目录配置边界,`feat/client-runner-adapter-profile-delicious233` 已补 generic adapter profile / 命令模板最小层,后续继续补真实 Runner adapter。
1616

1717
## 近期任务
1818

@@ -30,6 +30,7 @@
3030
- [x] 抽出 Edge Run lifecycle executor 边界,替换 handler 内置 mock flow。
3131
- [x] 增加可测试的本地进程 executor,覆盖 stdout/stderr 输出、正常退出、非零退出、取消和重复启动。
3232
- [x] 增加本地进程工作目录配置,覆盖构造期目录验证和子进程实际运行目录;这不是完整 Claude Code / Codex / OpenCode adapter。
33+
- [x] 增加 generic adapter profile / 命令模板最小层,覆盖 args/env 的 Run 占位符展开、未知占位符错误、固定 args 兼容和 workdir 不回退。
3334
- [ ] 将真实 Runner adapter 接入 Edge Run lifecycle。
3435
- [ ] 细化 Project / Thread / Item 的 OpenAPI 响应 schema。
3536

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type config struct {
1717
StoreFile string
1818
RunnerCommand string
1919
RunnerArgs repeatedString
20+
RunnerEnv repeatedString
2021
RunnerWorkDir string
2122
}
2223

@@ -51,9 +52,10 @@ func main() {
5152
serverConfig := httpserver.Config{Addr: cfg.Addr, Store: repository}
5253
if cfg.RunnerCommand != "" {
5354
serverConfig.ProcessExecutor = lifecycle.ProcessExecutorConfig{
54-
Command: cfg.RunnerCommand,
55-
Args: append([]string(nil), cfg.RunnerArgs...),
56-
WorkDir: cfg.RunnerWorkDir,
55+
Command: cfg.RunnerCommand,
56+
Args: append([]string(nil), cfg.RunnerArgs...),
57+
ExtraEnv: append([]string(nil), cfg.RunnerEnv...),
58+
WorkDir: cfg.RunnerWorkDir,
5759
}
5860
}
5961

@@ -73,16 +75,23 @@ func buildConfig(args []string) (config, error) {
7375
fs.StringVar(&cfg.RunnerCommand, "runner-command", "", "local process command to execute for each run; empty uses the mock executor")
7476
fs.StringVar(&cfg.RunnerWorkDir, "runner-workdir", "", "working directory for --runner-command; empty inherits the edge process working directory")
7577
fs.Var(&cfg.RunnerArgs, "runner-arg", "argument passed to --runner-command; may be repeated")
78+
fs.Var(&cfg.RunnerEnv, "runner-env", "environment variable passed to --runner-command as KEY=VALUE; may be repeated")
7679
if err := fs.Parse(args); err != nil {
7780
return config{}, err
7881
}
7982
cfg.RunnerCommand = strings.TrimSpace(cfg.RunnerCommand)
8083
if cfg.RunnerCommand == "" && len(cfg.RunnerArgs) > 0 {
8184
return config{}, fmt.Errorf("--runner-arg requires --runner-command")
8285
}
86+
if cfg.RunnerCommand == "" && len(cfg.RunnerEnv) > 0 {
87+
return config{}, fmt.Errorf("--runner-env requires --runner-command")
88+
}
8389
if cfg.RunnerCommand == "" && cfg.RunnerWorkDir != "" {
8490
return config{}, fmt.Errorf("--runner-workdir requires --runner-command")
8591
}
92+
if _, err := lifecycle.NewCommandTemplate(nil, cfg.RunnerEnv); err != nil {
93+
return config{}, fmt.Errorf("--runner-env: %w", err)
94+
}
8695
if fs.NArg() != 0 {
8796
return config{}, fmt.Errorf("unexpected positional arguments: %v", fs.Args())
8897
}

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ func TestBuildConfigDefaultsToMemoryStore(t *testing.T) {
3030
if len(cfg.RunnerArgs) != 0 {
3131
t.Fatalf("RunnerArgs = %#v, want empty", cfg.RunnerArgs)
3232
}
33+
if len(cfg.RunnerEnv) != 0 {
34+
t.Fatalf("RunnerEnv = %#v, want empty", cfg.RunnerEnv)
35+
}
3336
}
3437

3538
func TestBuildConfigParsesStoreFile(t *testing.T) {
@@ -40,6 +43,8 @@ func TestBuildConfigParsesStoreFile(t *testing.T) {
4043
"--runner-workdir", "workspace",
4144
"--runner-arg", "--mock",
4245
"--runner-arg", "--addr=127.0.0.1:0",
46+
"--runner-env", "AGENTHUB_PROFILE_RUN={{run.id}}",
47+
"--runner-env", "AGENTHUB_PROFILE_THREAD={{run.threadId}}",
4348
})
4449
if err != nil {
4550
t.Fatalf("buildConfig returned error: %v", err)
@@ -60,6 +65,9 @@ func TestBuildConfigParsesStoreFile(t *testing.T) {
6065
if got, want := []string(cfg.RunnerArgs), []string{"--mock", "--addr=127.0.0.1:0"}; strings.Join(got, "\x00") != strings.Join(want, "\x00") {
6166
t.Fatalf("RunnerArgs = %#v, want %#v", got, want)
6267
}
68+
if got, want := []string(cfg.RunnerEnv), []string{"AGENTHUB_PROFILE_RUN={{run.id}}", "AGENTHUB_PROFILE_THREAD={{run.threadId}}"}; strings.Join(got, "\x00") != strings.Join(want, "\x00") {
69+
t.Fatalf("RunnerEnv = %#v, want %#v", got, want)
70+
}
6371
}
6472

6573
func TestBuildConfigRejectsUnexpectedArguments(t *testing.T) {
@@ -76,6 +84,25 @@ func TestBuildConfigRejectsRunnerArgsWithoutCommand(t *testing.T) {
7684
}
7785
}
7886

87+
func TestBuildConfigRejectsRunnerEnvWithoutCommand(t *testing.T) {
88+
_, err := buildConfig([]string{"--runner-env", "AGENTHUB_PROFILE_RUN={{run.id}}"})
89+
if err == nil || !strings.Contains(err.Error(), "--runner-env requires --runner-command") {
90+
t.Fatalf("buildConfig error = %v, want runner command requirement", err)
91+
}
92+
}
93+
94+
func TestBuildConfigRejectsInvalidRunnerEnv(t *testing.T) {
95+
tests := []string{"AGENTHUB_PROFILE_RUN", "=value"}
96+
for _, value := range tests {
97+
t.Run(value, func(t *testing.T) {
98+
_, err := buildConfig([]string{"--runner-command", "agenthub-runner", "--runner-env", value})
99+
if err == nil || !strings.Contains(err.Error(), "--runner-env") {
100+
t.Fatalf("buildConfig error = %v, want runner env validation error", err)
101+
}
102+
})
103+
}
104+
}
105+
79106
func TestBuildConfigRejectsRunnerWorkDirWithoutCommand(t *testing.T) {
80107
_, err := buildConfig([]string{"--runner-workdir", "workspace"})
81108
if err == nil || !strings.Contains(err.Error(), "--runner-workdir requires --runner-command") {

edge-server/internal/lifecycle/process_executor.go

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,17 @@ var ErrProcessCommandRequired = errors.New("process command is required")
1818
var ErrProcessStoreRequired = errors.New("process store is required")
1919

2020
type ProcessExecutorConfig struct {
21-
Command string
22-
Args []string
23-
Env []string
24-
WorkDir string
21+
Command string
22+
Args []string
23+
Env []string
24+
ExtraEnv []string
25+
WorkDir string
2526
}
2627

2728
type ProcessExecutor struct {
2829
bus *events.Bus
2930
store store.RunLifecycleStore
30-
command string
31-
args []string
32-
env []string
33-
workDir string
31+
profile RunnerProfile
3432

3533
mu sync.Mutex
3634
running map[string]context.CancelFunc
@@ -43,8 +41,9 @@ func NewProcessExecutor(bus *events.Bus, store store.RunLifecycleStore, cfg Proc
4341
if store == nil {
4442
return nil, ErrProcessStoreRequired
4543
}
46-
if cfg.Command == "" {
47-
return nil, ErrProcessCommandRequired
44+
profile, err := NewGenericRunnerProfile(cfg.Command, cfg.Args, cfg.Env, cfg.ExtraEnv, cfg.WorkDir)
45+
if err != nil {
46+
return nil, err
4847
}
4948
if cfg.WorkDir != "" {
5049
info, err := os.Stat(cfg.WorkDir)
@@ -58,10 +57,7 @@ func NewProcessExecutor(bus *events.Bus, store store.RunLifecycleStore, cfg Proc
5857
return &ProcessExecutor{
5958
bus: bus,
6059
store: store,
61-
command: cfg.Command,
62-
args: append([]string(nil), cfg.Args...),
63-
env: append([]string(nil), cfg.Env...),
64-
workDir: cfg.WorkDir,
60+
profile: profile,
6561
running: make(map[string]context.CancelFunc),
6662
}, nil
6763
}
@@ -121,9 +117,19 @@ func (e *ProcessExecutor) Cancel(runID string) CancelResult {
121117
func (e *ProcessExecutor) run(ctx context.Context, run store.Run) {
122118
defer e.finish(run.ID)
123119

124-
cmd := exec.CommandContext(ctx, e.command, e.args...)
125-
cmd.Dir = e.workDir
126-
cmd.Env = e.envForRun(run)
120+
args, env, err := e.profile.Template.Expand(RunProcessContext{Run: run})
121+
if err != nil {
122+
e.publishFailed(run, err)
123+
return
124+
}
125+
_, extraEnv, err := e.profile.ExtraEnvTemplate.Expand(RunProcessContext{Run: run})
126+
if err != nil {
127+
e.publishFailed(run, err)
128+
return
129+
}
130+
cmd := exec.CommandContext(ctx, e.profile.Command, args...)
131+
cmd.Dir = e.profile.WorkDir
132+
cmd.Env = e.envForRun(run, env, extraEnv)
127133
stdout, err := cmd.StdoutPipe()
128134
if err != nil {
129135
e.publishFailed(run, fmt.Errorf("open stdout pipe: %w", err))
@@ -194,13 +200,14 @@ func (e *ProcessExecutor) publishOutput(wg *sync.WaitGroup, run store.Run, strea
194200
}
195201
}
196202

197-
func (e *ProcessExecutor) envForRun(run store.Run) []string {
198-
env := e.env
203+
func (e *ProcessExecutor) envForRun(run store.Run, profileEnv, extraEnv []string) []string {
204+
env := profileEnv
199205
if env == nil {
200206
env = os.Environ()
201207
} else {
202208
env = append([]string(nil), env...)
203209
}
210+
env = append(env, extraEnv...)
204211
return append(env,
205212
"AGENTHUB_RUN_ID="+run.ID,
206213
"AGENTHUB_PROJECT_ID="+run.ProjectID,

0 commit comments

Comments
 (0)