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
5 changes: 3 additions & 2 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`,`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。

## 路线图分层

Expand Down Expand Up @@ -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。

## 验收门槛

Expand All @@ -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 规划最小测试夹具。
34 changes: 34 additions & 0 deletions docs/roadmaps/branches/feat-client-runner-context-delicious233.md
Original file line number Diff line number Diff line change
@@ -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 ./...`
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 启动参数,`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。

## 近期任务

Expand All @@ -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。

Expand Down
13 changes: 12 additions & 1 deletion runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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...
Expand All @@ -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 执行过程。

### 运行测试

Expand Down
5 changes: 3 additions & 2 deletions runner/cmd/agenthub-runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions runner/internal/run/context.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions runner/internal/run/context_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 22 additions & 1 deletion runner/internal/run/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions runner/internal/run/mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}))
Expand Down
Loading