From ea4ba6c1e05d4673b81a21773c3b5282142f5e9a Mon Sep 17 00:00:00 2001 From: Delicious233 Date: Sat, 23 May 2026 07:21:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E6=8E=A5=E5=85=A5=20Runner=20m?= =?UTF-8?q?ock=20=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/roadmap.md | 5 +- ...feat-client-runner-context-delicious233.md | 34 ++++++++++++ docs/roadmaps/client.md | 3 +- runner/README.md | 13 ++++- runner/cmd/agenthub-runner/main.go | 5 +- runner/internal/run/context.go | 38 +++++++++++++ runner/internal/run/context_test.go | 39 ++++++++++++++ runner/internal/run/mock.go | 23 +++++++- runner/internal/run/mock_test.go | 53 +++++++++++++++++++ 9 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 docs/roadmaps/branches/feat-client-runner-context-delicious233.md create mode 100644 runner/internal/run/context.go create mode 100644 runner/internal/run/context_test.go diff --git a/docs/roadmap.md b/docs/roadmap.md index 45d5222c6..a0b1d454b 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 边界,`feat/client-runner-workdir-delicious233` 已补本地进程工作目录边界,`feat/client-runner-adapter-profile-delicious233` 已补 generic adapter profile / 命令模板最小层,`feat/client-runner-profile-preset-delicious233` 已补 `agenthub-runner-mock` preset 入口,下一步重点是真实 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` 已补本地进程工作目录边界,`feat/client-runner-adapter-profile-delicious233` 已补 generic adapter profile / 命令模板最小层,`feat/client-runner-profile-preset-delicious233` 已补 `agenthub-runner-mock` preset 入口,`feat/client-runner-context-delicious233` 已让仓库自带 Runner mock 读取 Edge 注入的 Run 上下文,下一步重点是真实 Runner adapter。 ## 路线图分层 @@ -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,`feat/client-runner-workdir-delicious233` 增加本地进程工作目录配置,`feat/client-runner-adapter-profile-delicious233` 增加可测试的 generic adapter profile / 命令模板最小层,`feat/client-runner-profile-preset-delicious233` 增加可测试的 `agenthub-runner-mock` preset 入口,后续继续做真实 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` 增加本地进程工作目录配置,`feat/client-runner-adapter-profile-delicious233` 增加可测试的 generic adapter profile / 命令模板最小层,`feat/client-runner-profile-preset-delicious233` 增加可测试的 `agenthub-runner-mock` preset 入口,`feat/client-runner-context-delicious233` 让 mock Runner stdout 稳定带上 Run / Project / Thread 上下文,后续继续做真实 Runner adapter。 ## 验收门槛 @@ -54,6 +54,7 @@ - [x] 为 Edge 本地进程 executor 增加工作目录配置边界;这只是本地进程 workdir 能力,不是完整真实 Runner adapter。 - [x] 为 Edge 本地进程 executor 增加 generic adapter profile / 命令模板最小层,支持从 Run 上下文展开 args/env。 - [x] 为 Edge 启动入口增加 `--runner-profile agenthub-runner-mock` preset,默认使用仓库自带 Runner mock CLI。 +- [x] 让仓库自带 `agenthub-runner --mock` 读取 Edge 注入的 Run / Project / Thread 环境变量,并在 stdout 输出稳定上下文行。 - [ ] 将 Runner 真正接入 Edge Run lifecycle,替换 handler 内置 mock flow。 - [ ] M2 完成后归档或更新 `docs/client-roadmap.md`,避免路线图重复。 - [ ] 为 Runner 真实 CLI adapter 规划最小测试夹具。 diff --git a/docs/roadmaps/branches/feat-client-runner-context-delicious233.md b/docs/roadmaps/branches/feat-client-runner-context-delicious233.md new file mode 100644 index 000000000..63adf63ca --- /dev/null +++ b/docs/roadmaps/branches/feat-client-runner-context-delicious233.md @@ -0,0 +1,34 @@ +# feat/client-runner-context-delicious233 路线图 + +最后更新:2026-05-23 + +## 当前目标 + +- [x] 让仓库自带 `agenthub-runner --mock` 读取 Edge ProcessExecutor 注入的 Run 上下文环境变量。 + +## 写入范围 + +- `runner/cmd/agenthub-runner/` +- `runner/internal/run/` +- `runner/README.md` +- `docs/roadmap.md` +- `docs/roadmaps/client.md` +- `docs/roadmaps/branches/feat-client-runner-context-delicious233.md` + +## 已完成 + +- [x] 新增 `RunContext` / `ContextFromEnv` 读取边界,读取 `AGENTHUB_RUN_ID`、`AGENTHUB_PROJECT_ID`、`AGENTHUB_THREAD_ID`。 +- [x] `AGENTHUB_RUN_ID` 为空时保留 `mock-run-1` 默认值。 +- [x] mock mode 使用 env run ID 创建 `MockRun`,stdout 稳定输出 `run=`、`project=`、`thread=` 三行上下文。 +- [x] 同步 Runner README、总路线图和客户端路线图。 + +## 下一步 + +- [ ] 继续规划真实 Runner adapter,但不在本分支接 Claude Code / Codex / OpenCode。 + +## 验收 + +- [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 runner; go test -count=1 ./...` +- [x] `cd edge-server; go test -count=1 ./...` diff --git a/docs/roadmaps/client.md b/docs/roadmaps/client.md index 813a2a21d..17538a7f8 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 边界,`feat/client-runner-workdir-delicious233` 已补本地进程工作目录配置边界,`feat/client-runner-adapter-profile-delicious233` 已补 generic adapter profile / 命令模板最小层,`feat/client-runner-profile-preset-delicious233` 已补仓库自带 mock Runner preset 入口,后续继续补真实 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` 已补本地进程工作目录配置边界,`feat/client-runner-adapter-profile-delicious233` 已补 generic adapter profile / 命令模板最小层,`feat/client-runner-profile-preset-delicious233` 已补仓库自带 mock Runner preset 入口,`feat/client-runner-context-delicious233` 已补 Runner mock 读取 Edge 注入上下文的最小契约,后续继续补真实 Runner adapter。 ## 近期任务 @@ -32,6 +32,7 @@ - [x] 增加本地进程工作目录配置,覆盖构造期目录验证和子进程实际运行目录;这不是完整 Claude Code / Codex / OpenCode adapter。 - [x] 增加 generic adapter profile / 命令模板最小层,覆盖 args/env 的 Run 占位符展开、未知占位符错误、固定 args 兼容和 workdir 不回退。 - [x] 增加 `--runner-profile agenthub-runner-mock`,覆盖默认 command、command override、用户 args/env 追加和未知 profile 错误。 +- [x] 增加 Runner mock 上下文读取边界,覆盖 `AGENTHUB_RUN_ID` 默认值、env 注入 run ID 和 stdout 上下文输出。 - [ ] 将真实 Runner adapter 接入 Edge Run lifecycle。 - [ ] 细化 Project / Thread / Item 的 OpenAPI 响应 schema。 diff --git a/runner/README.md b/runner/README.md index 207a123a2..627cd2868 100644 --- a/runner/README.md +++ b/runner/README.md @@ -19,6 +19,14 @@ go build ./cmd/agenthub-runner go run ./cmd/agenthub-runner --mock ``` +Mock 模式会读取 Edge ProcessExecutor 注入的最小 Run 上下文: + +| 环境变量 | 说明 | +|---|---| +| `AGENTHUB_RUN_ID` | 当前 Run ID;为空时兼容使用 `mock-run-1` | +| `AGENTHUB_PROJECT_ID` | 当前 Project ID;为空时照常输出空值 | +| `AGENTHUB_THREAD_ID` | 当前 Thread ID;为空时照常输出空值 | + ### 通过 Edge Runner profile 启动 开发 Edge 本地进程接入时,可以用仓库自带 mock Runner preset: @@ -47,6 +55,9 @@ agenthub-edge --runner-profile agenthub-runner-mock --runner-command agenthub-ru ```text INFO starting agent runner in mock mode addr=127.0.0.1:3211 INFO mock run started id=mock-run-1 +run=mock-run-1 +project= +thread= Installing dependencies... Building project... Running tests... @@ -55,7 +66,7 @@ INFO mock run finished id=mock-run-1 INFO mock run completed successfully ``` -输出之间有约 80ms 间隔,模拟真实 agent 执行过程。 +前三行 stdout 上下文是稳定输出,便于 Edge 的 `run.output.batch` 事件中看到真实 Run / Project / Thread 关联;后续 mock chunk 之间有约 80ms 间隔,模拟真实 agent 执行过程。 ### 运行测试 diff --git a/runner/cmd/agenthub-runner/main.go b/runner/cmd/agenthub-runner/main.go index cb34af584..0597d4c77 100644 --- a/runner/cmd/agenthub-runner/main.go +++ b/runner/cmd/agenthub-runner/main.go @@ -22,9 +22,10 @@ func main() { os.Exit(1) } - slog.Info("starting agent runner in mock mode", "addr", *addr) + ctx := run.ContextFromEnv() + slog.Info("starting agent runner in mock mode", "addr", *addr, "runID", ctx.RunID) - m := run.NewMockRun("mock-run-1") + m := run.NewMockRunFromContext(ctx) if err := m.Start(); err != nil { slog.Error("mock run failed", "error", err) os.Exit(1) diff --git a/runner/internal/run/context.go b/runner/internal/run/context.go new file mode 100644 index 000000000..234e9fed7 --- /dev/null +++ b/runner/internal/run/context.go @@ -0,0 +1,38 @@ +package run + +import "os" + +const ( + // DefaultMockRunID keeps the mock runner compatible when Edge has not + // injected a run context yet. + DefaultMockRunID = "mock-run-1" + + envRunID = "AGENTHUB_RUN_ID" + envProjectID = "AGENTHUB_PROJECT_ID" + envThreadID = "AGENTHUB_THREAD_ID" +) + +// RunContext is the minimal run scope injected by Edge into the runner process. +type RunContext struct { + RunID string + ProjectID string + ThreadID string +} + +// ContextFromEnv reads the Edge-injected runner context from process +// environment variables. +func ContextFromEnv() RunContext { + ctx := RunContext{ + RunID: os.Getenv(envRunID), + ProjectID: os.Getenv(envProjectID), + ThreadID: os.Getenv(envThreadID), + } + return normalizeRunContext(ctx) +} + +func normalizeRunContext(ctx RunContext) RunContext { + if ctx.RunID == "" { + ctx.RunID = DefaultMockRunID + } + return ctx +} diff --git a/runner/internal/run/context_test.go b/runner/internal/run/context_test.go new file mode 100644 index 000000000..e71919438 --- /dev/null +++ b/runner/internal/run/context_test.go @@ -0,0 +1,39 @@ +package run + +import "testing" + +func TestContextFromEnvUsesInjectedValues(t *testing.T) { + t.Setenv(envRunID, "run-env-1") + t.Setenv(envProjectID, "project-env-1") + t.Setenv(envThreadID, "thread-env-1") + + ctx := ContextFromEnv() + + if ctx.RunID != "run-env-1" { + t.Errorf("expected RunID from env, got %q", ctx.RunID) + } + if ctx.ProjectID != "project-env-1" { + t.Errorf("expected ProjectID from env, got %q", ctx.ProjectID) + } + if ctx.ThreadID != "thread-env-1" { + t.Errorf("expected ThreadID from env, got %q", ctx.ThreadID) + } +} + +func TestContextFromEnvDefaultsRunID(t *testing.T) { + t.Setenv(envRunID, "") + t.Setenv(envProjectID, "project-env-1") + t.Setenv(envThreadID, "thread-env-1") + + ctx := ContextFromEnv() + + if ctx.RunID != DefaultMockRunID { + t.Errorf("expected default RunID %q, got %q", DefaultMockRunID, ctx.RunID) + } + if ctx.ProjectID != "project-env-1" { + t.Errorf("expected ProjectID from env, got %q", ctx.ProjectID) + } + if ctx.ThreadID != "thread-env-1" { + t.Errorf("expected ThreadID from env, got %q", ctx.ThreadID) + } +} diff --git a/runner/internal/run/mock.go b/runner/internal/run/mock.go index 485262bc4..b76c54822 100644 --- a/runner/internal/run/mock.go +++ b/runner/internal/run/mock.go @@ -22,6 +22,7 @@ var DefaultOutputChunks = []string{ // MockRun represents a simulated agent execution. type MockRun struct { id string + context RunContext state *process.StateMachine outputChunks []string chunkDelay time.Duration @@ -52,10 +53,20 @@ func WithWriter(w io.Writer) MockRunOption { } } +// WithRunContext sets the Edge-injected run context for mock output. +func WithRunContext(ctx RunContext) MockRunOption { + return func(m *MockRun) { + m.context = normalizeRunContext(ctx) + m.id = m.context.RunID + } +} + // NewMockRun creates a new MockRun with the given ID and options. func NewMockRun(id string, opts ...MockRunOption) *MockRun { + ctx := normalizeRunContext(RunContext{RunID: id}) m := &MockRun{ - id: id, + id: ctx.RunID, + context: ctx, state: process.NewStateMachine(), outputChunks: DefaultOutputChunks, chunkDelay: 80 * time.Millisecond, @@ -67,6 +78,12 @@ func NewMockRun(id string, opts ...MockRunOption) *MockRun { return m } +// NewMockRunFromContext creates a mock run from Edge-injected context. +func NewMockRunFromContext(ctx RunContext, opts ...MockRunOption) *MockRun { + ctx = normalizeRunContext(ctx) + return NewMockRun(ctx.RunID, append([]MockRunOption{WithRunContext(ctx)}, opts...)...) +} + // Start begins the mock run simulation. // It transitions from idle to running, outputs chunks with delays, // then transitions to finished. @@ -77,6 +94,10 @@ func (m *MockRun) Start() error { slog.Info("mock run started", "id", m.id) + fmt.Fprintf(m.writer, "run=%s\n", m.context.RunID) + fmt.Fprintf(m.writer, "project=%s\n", m.context.ProjectID) + fmt.Fprintf(m.writer, "thread=%s\n", m.context.ThreadID) + for _, chunk := range m.outputChunks { fmt.Fprintln(m.writer, chunk) time.Sleep(m.chunkDelay) diff --git a/runner/internal/run/mock_test.go b/runner/internal/run/mock_test.go index 04f0ef8c3..d9e3b1fd5 100644 --- a/runner/internal/run/mock_test.go +++ b/runner/internal/run/mock_test.go @@ -31,6 +31,59 @@ func TestMockRunStartProducesOutput(t *testing.T) { } } +func TestMockRunFromContextUsesRunID(t *testing.T) { + var buf bytes.Buffer + m := NewMockRunFromContext(RunContext{ + RunID: "run-context-1", + ProjectID: "project-context-1", + ThreadID: "thread-context-1", + }, WithWriter(&buf)) + + if m.ID() != "run-context-1" { + t.Errorf("expected context run ID, got %q", m.ID()) + } +} + +func TestMockRunFromContextDefaultsRunID(t *testing.T) { + var buf bytes.Buffer + m := NewMockRunFromContext(RunContext{}, WithWriter(&buf)) + + if m.ID() != DefaultMockRunID { + t.Errorf("expected default run ID %q, got %q", DefaultMockRunID, m.ID()) + } +} + +func TestMockRunStartProducesContextOutput(t *testing.T) { + var buf bytes.Buffer + m := NewMockRunFromContext(RunContext{ + RunID: "run-context-2", + ProjectID: "project-context-2", + ThreadID: "thread-context-2", + }, WithWriter(&buf)) + + if err := m.Start(); err != nil { + t.Fatalf("Start failed: %v", err) + } + + output := buf.String() + expectedContextLines := []string{ + "run=run-context-2\n", + "project=project-context-2\n", + "thread=thread-context-2\n", + } + for _, line := range expectedContextLines { + if !strings.Contains(output, line) { + t.Errorf("expected output to contain %q", line) + } + } + + for i, line := range expectedContextLines { + if !strings.HasPrefix(output, strings.Join(expectedContextLines[:i+1], "")) { + t.Fatalf("expected context line %d to be %q in the first stdout lines, got output:\n%s", i+1, line, output) + } + } +} + func TestMockRunStateTransitions(t *testing.T) { t.Run("running to finished via Start", func(t *testing.T) { m := NewMockRun("test-ft", WithWriter(&bytes.Buffer{}))